From 8927805c5f88f9de880f4b1b317390315167b9d5 Mon Sep 17 00:00:00 2001 From: Antonis Angelakis Date: Thu, 18 Mar 2021 03:21:04 +0200 Subject: [PATCH 1/8] Add proper token exchange tests Fix merge --- src/oidcop/oidc/token.py | 168 +++++++++ tests/test_36_oauth2_token_exchange.py | 488 +++++++++++++++++++++---- 2 files changed, 595 insertions(+), 61 deletions(-) diff --git a/src/oidcop/oidc/token.py b/src/oidcop/oidc/token.py index 337588b8..93352b79 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -352,6 +352,173 @@ 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) + + # TODO: should we even have a policy for the simple use cases? + if config is None: + self.policy = {} + else: + self.policy = config.get('policy', {}) + + # TODO: Make this a part of the policy. Note the distinction between + # requested_token_type, subject_token_type, actor_token_type, issued_token_type + self.token_types_allowed = [ + "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:jwt", + # "urn:ietf:params:oauth:token-type:id_token", + # "urn:ietf:params:oauth:token-type:refresh_token", + ] + + def post_parse_request(self, request, client_id="", **kwargs): + request = TokenExchangeRequest(**request.to_dict()) + + # if "client_id" not in request: + # request["client_id"] = client_id + + keyjar = getattr(self.endpoint_context, "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 + ) + + + error = self.check_for_errors(request=request) + if error is not None: + return error + + _mngr = self.endpoint_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 not isinstance(token, AccessToken): + return self.error_cls( + error="invalid_request", error_description="Wrong token type" + ) + + if token.is_active() is False: + return self.error_cls( + error="invalid_request", error_description="Subject token inactive" + ) + + return request + + def check_for_errors(self, request): + context = self.endpoint.endpoint_context + if "resource" in request: + iss = urlparse(context.issuer) + if any( + urlparse(res).netloc != iss.netloc for res in request["resource"] + ): + return TokenErrorResponse( + error="invalid_target", error_description="Unknown resource" + ) + + if "audience" in request: + if any( + aud != context.issuer for aud in request["audience"] + ): + return TokenErrorResponse( + error="invalid_target", error_description="Unknown audience" + ) + + # TODO: if requested type is jwt make sure our tokens are jwt + if ( + "requested_token_type" in request + and request["requested_token_type"] not in self.token_types_allowed + ): + return TokenErrorResponse( + error="invalid_target", + error_description="Unsupported requested token type" + ) + + if "actor_token" in request or "actor_token_type" in request: + return TokenErrorResponse( + error="invalid_request", error_description="Actor token not supported" + ) + + # TODO: also check if the (valid) subject_token matches subject_token_type + if request["subject_token_type"] not in self.token_types_allowed: + return TokenErrorResponse( + error="invalid_request", + error_description="Unsupported subject token type", + ) + + def token_exchange_response(self, token): + response_args = { + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": token.type, + "access_token": token.value, + "scope": token.scope, + "expires_in": token.usage_rules["expires_in"] + } + return TokenExchangeResponse(**response_args) + + def process_request(self, request, **kwargs): + # TODO: should we even have a policy for the simple use cases? + # client_policy = self.policy.get(req["client_id"]) or self.policy.get("default") + # if not client_policy: + # logger.error( + # "TokenExchange policy for client {req['client_id']} or default missing." + # ) + # return TokenErrorResponse( + # error="invalid_request", error_description="Not allowed" + # ) + _mngr = self.endpoint_context.session_manager + try: + _session_info = _mngr.get_session_info_by_token( + request["subject_token"], + grant=True, + ) + except KeyError: + logger.error("Subject token invalid.") + return self.error_cls( + error="invalid_grant", + error_description="Subject token invalid", + ) + + token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) + grant = _session_info["grant"] + + try: + new_token = grant.mint_token( + session_id=_session_info["session_id"], + endpoint_context=self.endpoint_context, + token_type='access_token', + token_handler=_mngr.token_handler["access_token"], + based_on=token, + resources=request.get("resource"), + scope=request.get("scope"), + ) + except MintingNotAllowed: + logger.error("Minting not allowed for 'access_token'") + return self.error_cls( + error="invalid_grant", + error_description="Token Exchange not allowed with that token", + ) + + return self.token_exchange_response(token=new_token) class Token(oauth2.token.Token): request_cls = Message @@ -367,4 +534,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/tests/test_36_oauth2_token_exchange.py b/tests/test_36_oauth2_token_exchange.py index 11147e91..a405f3b3 100644 --- a/tests/test_36_oauth2_token_exchange.py +++ b/tests/test_36_oauth2_token_exchange.py @@ -7,6 +7,7 @@ from oidcmsg.oidc import AuthorizationRequest import pytest + from oidcop.authn_event import create_authn_event from oidcop.authz import AuthzHandling from oidcop.client_authn import verify_client @@ -135,7 +136,10 @@ def create_endpoint(self): "supports_minting": ["access_token", "refresh_token", "id_token", ], "max_usage": 1, }, - "access_token": {}, + "access_token": { + "supports_minting": ["access_token", "refresh_token", "id_token"], + "expires_in": 600, + }, "refresh_token": { "supports_minting": ["access_token", "refresh_token"], }, @@ -169,6 +173,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"], } endpoint_context.keyjar.import_jwks(CLIENT_KEYJAR.export_jwks(), "client_1") self.endpoint = server.server_get("endpoint", "token") @@ -188,100 +193,461 @@ 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 = session_key(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 + + def test_token_exchange(self): + """ + Test that token exchange requests work correctly, removing a scope. + """ + areq = AUTH_REQ.copy() + areq["scope"] = ["openid", "profile"] + + session_id = self._create_session(areq) + grant = self.endpoint.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) - 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) + _cntx = self.endpoint.endpoint_context - # 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 + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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"], + scope=["openid"], ) - return exchange_grant - def test_do_response(self): - session_id = self._create_session(AUTH_REQ) + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + ) + _resp = self.endpoint.process_request(request=_req) - grant = self.session_manager.get_grant(session_id) - grant.usage_rules["access_token"] = {"supports_minting": ["access_token"]} + assert set(_resp.keys()) == {"response_args", "http_headers"} + assert set(_resp["response_args"].keys()) == { + 'access_token', 'token_type', 'scope', 'expires_in', 'issued_token_type' + } + assert _resp["response_args"]["scope"] == ["openid"] - 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) + def test_additional_parameters(self): + """ + Test that a token exchange with additional parameters including + audience and subject_token_type works. + """ + areq = AUTH_REQ.copy() + + session_id = self._create_session(areq) + grant = self.endpoint.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) + + _cntx = self.endpoint.endpoint_context _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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"], + requested_token_type="urn:ietf:params:oauth:token-type:access_token", + audience=["https://example.com/"], + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + ) _resp = self.endpoint.process_request(request=_req) + + assert set(_resp.keys()) == {"response_args", "http_headers"} + assert set(_resp["response_args"].keys()) == { + 'access_token', 'token_type', 'expires_in', 'issued_token_type' + } 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" + ] - ter = TokenExchangeRequest( - subject_token=token_response["access_token"], - subject_token_type="urn:ietf:params:oauth:token-type:access_token", + areq = AUTH_REQ.copy() + + session_id = self._create_session(areq) + grant = self.endpoint.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) + + _cntx = self.endpoint.endpoint_context + + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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://example.com/api"] + ) + + _resp = self.endpoint.parse_request( + token_exchange_req.to_json(), + auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + ) + assert set(_resp.keys()) == {"error", "error_description"} + assert _resp["error"] == "invalid_request" + assert( + _resp["error_description"] + == "Unsupported grant_type: urn:ietf:params:oauth:grant-type:token-exchange" ) - 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) + def test_wrong_resource(self): + """ + Test that requesting a token for an unknown resource fails. + + We currently only allow resources that match the issuer's host part. + TODO: Should we do this? + """ + areq = AUTH_REQ.copy() - assert exch_grants - exch_grant = exch_grants[0] + session_id = self._create_session(areq) + grant = self.endpoint.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) - 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"]) + _cntx = self.endpoint.endpoint_context - session_id = self.session_manager.encrypted_session_id( - session_info["user_id"], session_info["client_id"], exch_grant.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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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://unknown-resource.com/api"] + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + auth="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" - _scope = session_info["grant"].find_scope(ter["subject_token"]) + def test_wrong_audience(self): + """ + Test that requesting a token for an unknown audience fails. + We currently only allow audience that match the issuer. + TODO: Should we do this? + """ + areq = AUTH_REQ.copy() + + session_id = self._create_session(areq) + grant = self.endpoint.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) + + _cntx = self.endpoint.endpoint_context _token = self._mint_access_token( exch_grant, session_id, token_ref=_token, resources=["https://backend.example.com"], scope=_scope ) - 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"], - } + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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/"], + resource=["https://example.com/api"] + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + auth="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 audience" + + @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.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) + + _cntx = self.endpoint.endpoint_context + + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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(), + auth="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:refresh_token", + "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.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) + + _cntx = self.endpoint.endpoint_context + + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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(), + auth="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"] + == "Unsupported requested token type" + ) + + @pytest.mark.parametrize("unsupported_type", [ + "unknown", + "urn:ietf:params:oauth:token-type:refresh_token", + "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.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) + + _cntx = self.endpoint.endpoint_context + + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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(), + auth="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.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) + + _cntx = self.endpoint.endpoint_context + + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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(), + auth="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.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq['client_id']) + + _cntx = self.endpoint.endpoint_context + + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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(), + auth="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" ) - _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"])) From 16aac13d71716b809fc6df6ef30cc45206bf645f Mon Sep 17 00:00:00 2001 From: Kostis Triantafyllakis Date: Wed, 6 Oct 2021 14:25:17 +0300 Subject: [PATCH 2/8] Port token exchange feature to oidcop Fix tests --- src/oidcop/oauth2/token.py | 2 +- src/oidcop/oidc/token.py | 84 +++++++++++---- tests/test_36_oauth2_token_exchange.py | 140 ++++++++++++++++--------- 3 files changed, 153 insertions(+), 73 deletions(-) diff --git a/src/oidcop/oauth2/token.py b/src/oidcop/oauth2/token.py index bfee6dff..d08d07b9 100755 --- a/src/oidcop/oauth2/token.py +++ b/src/oidcop/oauth2/token.py @@ -68,7 +68,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 = {} diff --git a/src/oidcop/oidc/token.py b/src/oidcop/oidc/token.py index 93352b79..6ec6aee8 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -1,22 +1,29 @@ 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.message import Message +from oidcmsg.exception import MissingRequiredValue, MissingRequiredAttribute 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.oauth2.authorization import check_unknown_scopes_policy from oidcop.oauth2.token import TokenEndpointHelper from oidcop.session.grant import AuthorizationCode from oidcop.session.grant import RefreshToken from oidcop.session.token import MintingNotAllowed from oidcop.token.exception import UnknownToken +from oidcop.exception import UnAuthorizedClientScope, ToOld +from oidcop.session.token import AccessToken logger = logging.getLogger(__name__) @@ -376,10 +383,15 @@ def __init__(self, endpoint, config=None): def post_parse_request(self, request, client_id="", **kwargs): request = TokenExchangeRequest(**request.to_dict()) - # if "client_id" not in request: - # request["client_id"] = client_id + if "client_id" not in request: + request["client_id"] = client_id + + _context = self.endpoint.server_get("endpoint_context") - keyjar = getattr(self.endpoint_context, "keyjar", "") + try: + keyjar = _context.keyjar + except AttributeError: + keyjar = "" try: request.verify(keyjar=keyjar, opponent_id=client_id) @@ -393,12 +405,11 @@ def post_parse_request(self, request, client_id="", **kwargs): error="invalid_request", error_description="%s" % err ) - error = self.check_for_errors(request=request) if error is not None: return error - _mngr = self.endpoint_context.session_manager + _mngr = _context.session_manager try: _session_info = _mngr.get_session_info_by_token( request["subject_token"], grant=True @@ -421,13 +432,12 @@ def post_parse_request(self, request, client_id="", **kwargs): return self.error_cls( error="invalid_request", error_description="Subject token inactive" ) - return request def check_for_errors(self, request): - context = self.endpoint.endpoint_context + _context = self.endpoint.server_get("endpoint_context") if "resource" in request: - iss = urlparse(context.issuer) + iss = urlparse(_context.issuer) if any( urlparse(res).netloc != iss.netloc for res in request["resource"] ): @@ -437,7 +447,7 @@ def check_for_errors(self, request): if "audience" in request: if any( - aud != context.issuer for aud in request["audience"] + aud != _context.issuer for aud in request["audience"] ): return TokenErrorResponse( error="invalid_target", error_description="Unknown audience" @@ -468,7 +478,7 @@ def check_for_errors(self, request): def token_exchange_response(self, token): response_args = { "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", - "token_type": token.type, + "token_type": token.token_type, "access_token": token.value, "scope": token.scope, "expires_in": token.usage_rules["expires_in"] @@ -485,31 +495,61 @@ def process_request(self, request, **kwargs): # return TokenErrorResponse( # error="invalid_request", error_description="Not allowed" # ) - _mngr = self.endpoint_context.session_manager + _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, + request["subject_token"], grant=True ) - except KeyError: + 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_grant", - error_description="Subject token invalid", + error="invalid_request", + error_description="Subject token invalid" ) token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) + + if not isinstance(token, AccessToken): + return self.error_cls( + error="invalid_request", error_description="Wrong token type" + ) + + if token.is_active() is False: + return self.error_cls( + error="invalid_request", error_description="Subject token inactive" + ) + grant = _session_info["grant"] + 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: - new_token = grant.mint_token( + new_token = self._mint_token( + token_class=token.token_class, + grant=grant, session_id=_session_info["session_id"], - endpoint_context=self.endpoint_context, - token_type='access_token', - token_handler=_mngr.token_handler["access_token"], + client_id=request["client_id"], based_on=token, - resources=request.get("resource"), scope=request.get("scope"), + token_args={ + "resources":request.get("resource"), + }, + token_type=token.token_type ) except MintingNotAllowed: logger.error("Minting not allowed for 'access_token'") diff --git a/tests/test_36_oauth2_token_exchange.py b/tests/test_36_oauth2_token_exchange.py index a405f3b3..36dfe2a0 100644 --- a/tests/test_36_oauth2_token_exchange.py +++ b/tests/test_36_oauth2_token_exchange.py @@ -101,14 +101,22 @@ 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", - ] + ], + "grant_types_supported": { + "urn:ietf:params:oauth:grant-type:token-exchange": { + "class": "oidcop.oidc.token.TokenExchangeHelper" + }, + "authorization_code": { + "class": "oidcop.oidc.token.AccessTokenHelper" + } + }, }, }, "introspection": { @@ -133,11 +141,11 @@ 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": { - "supports_minting": ["access_token", "refresh_token", "id_token"], + "supports_minting": ["access_token", "refresh_token"], "expires_in": 600, }, "refresh_token": { @@ -166,8 +174,8 @@ 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", @@ -175,10 +183,10 @@ def create_endpoint(self): "response_types": ["code", "token", "code id_token", "id_token"], "allowed_scopes": ["openid", "profile"], } - 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=""): @@ -194,7 +202,7 @@ def _create_session(self, auth_req, sub_type="public", sector_identifier=""): ) def _mint_code(self, grant, client_id): - session_id = session_key(self.user_id, client_id, grant.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") @@ -220,12 +228,11 @@ def test_token_exchange(self): """ areq = AUTH_REQ.copy() areq["scope"] = ["openid", "profile"] - session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -246,11 +253,13 @@ def test_token_exchange(self): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) - - assert set(_resp.keys()) == {"response_args", "http_headers"} assert set(_resp["response_args"].keys()) == { 'access_token', 'token_type', 'scope', 'expires_in', 'issued_token_type' } @@ -264,10 +273,10 @@ def test_additional_parameters(self): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -289,13 +298,16 @@ def test_additional_parameters(self): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) - assert set(_resp.keys()) == {"response_args", "http_headers"} assert set(_resp["response_args"].keys()) == { - 'access_token', 'token_type', 'expires_in', 'issued_token_type' + 'access_token', 'token_type', 'expires_in', 'issued_token_type', 'scope' } msg = self.endpoint.do_response(request=_req, **_resp) assert isinstance(msg, dict) @@ -312,10 +324,10 @@ def test_token_exchange_fails_if_disabled(self): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -333,11 +345,15 @@ def test_token_exchange_fails_if_disabled(self): resource=["https://example.com/api"] ) - _resp = self.endpoint.parse_request( + _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) - assert set(_resp.keys()) == {"error", "error_description"} + _resp = self.endpoint.process_request(request=_req) assert _resp["error"] == "invalid_request" assert( _resp["error_description"] @@ -354,10 +370,10 @@ def test_wrong_resource(self): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -377,7 +393,11 @@ def test_wrong_resource(self): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -394,14 +414,10 @@ def test_wrong_audience(self): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context - _token = self._mint_access_token( - exch_grant, session_id, token_ref=_token, resources=["https://backend.example.com"], - scope=_scope - ) + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -422,7 +438,11 @@ def test_wrong_audience(self): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -440,10 +460,10 @@ def test_missing_parameters(self, missing_attribute): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -466,7 +486,11 @@ def test_missing_parameters(self, missing_attribute): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -490,10 +514,10 @@ def test_unsupported_requested_token_type(self, unsupported_type): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -515,7 +539,11 @@ def test_unsupported_requested_token_type(self, unsupported_type): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -539,10 +567,10 @@ def test_unsupported_subject_token_type(self, unsupported_type): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -563,7 +591,11 @@ def test_unsupported_subject_token_type(self, unsupported_type): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -580,10 +612,10 @@ def test_unsupported_actor_token(self): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -603,7 +635,11 @@ def test_unsupported_actor_token(self): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -620,10 +656,10 @@ def test_invalid_token(self): areq = AUTH_REQ.copy() session_id = self._create_session(areq) - grant = self.endpoint.endpoint_context.authz(session_id, areq) + grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint.endpoint_context + _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -642,7 +678,11 @@ def test_invalid_token(self): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - auth="Basic {}".format("Y2xpZW50XzE6aGVtbGlndA=="), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} From da554e0e0f55a615c31fb3877ca24aa32dc24e94 Mon Sep 17 00:00:00 2001 From: Kostis Triantafyllakis Date: Tue, 12 Oct 2021 10:25:39 +0300 Subject: [PATCH 3/8] Fix token exchange claims on userinfo endpoint --- src/oidcop/oidc/userinfo.py | 8 +++++--- tests/test_26_oidc_userinfo_endpoint.py | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/oidcop/oidc/userinfo.py b/src/oidcop/oidc/userinfo.py index 6e6ce978..37d54d25 100755 --- a/src/oidcop/oidc/userinfo.py +++ b/src/oidcop/oidc/userinfo.py @@ -141,11 +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"): info["acr"] = _grant.authentication_event["authn_info"] 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 From 77251f1b6a8df66776fbfcc582304842549b41fb Mon Sep 17 00:00:00 2001 From: Kostis Triantafyllakis Date: Mon, 18 Oct 2021 14:33:24 +0300 Subject: [PATCH 4/8] Fix token exchange claims on introspection endpoint --- src/oidcop/oauth2/introspection.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From b40c5bc4e73f3cd6108ce108ecd8416f5d0b0e79 Mon Sep 17 00:00:00 2001 From: Kostis Triantafyllakis Date: Fri, 22 Oct 2021 14:32:09 +0300 Subject: [PATCH 5/8] Add support of refresh token on token exchange Support audience for refresh token --- docs/source/contents/conf.rst | 4 +- docs/source/contents/usage.md | 41 +++++++ docs/source/index.rst | 1 + src/oidcop/oidc/token.py | 161 +++++++++++++++++++------ tests/test_36_oauth2_token_exchange.py | 87 ++++++++++--- 5 files changed, 239 insertions(+), 55 deletions(-) diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst index 30ed142f..d258cd3a 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'] } } }, diff --git a/docs/source/contents/usage.md b/docs/source/contents/usage.md index 795cc0d3..64827b31 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 = "DBP60x3KUQfCYWZlqFaS_Q" + CLIENT_SECRET="8526270403788522b2444e87ea90c53bcafb984119cec92eeccc12f1" + SUBJECT_TOKEN="Z0FBQUFkF3czZRU...BfdTJkQXlCSm55cVpxQ1A0Y0RkWEtQTT0=" + 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://snf-19725.ok-kno.grnetcloud.net/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/oidc/token.py b/src/oidcop/oidc/token.py index 6ec6aee8..d5a8fd97 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -24,6 +24,7 @@ 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 logger = logging.getLogger(__name__) @@ -207,7 +208,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 @@ -216,6 +217,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"])) @@ -377,7 +380,7 @@ def __init__(self, endpoint, config=None): "urn:ietf:params:oauth:token-type:access_token", "urn:ietf:params:oauth:token-type:jwt", # "urn:ietf:params:oauth:token-type:id_token", - # "urn:ietf:params:oauth:token-type:refresh_token", + "urn:ietf:params:oauth:token-type:refresh_token", ] def post_parse_request(self, request, client_id="", **kwargs): @@ -423,7 +426,7 @@ def post_parse_request(self, request, client_id="", **kwargs): token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) - if not isinstance(token, AccessToken): + if not isinstance(token, (AccessToken, RefreshToken)): return self.error_cls( error="invalid_request", error_description="Wrong token type" ) @@ -436,6 +439,14 @@ def post_parse_request(self, request, client_id="", **kwargs): def check_for_errors(self, request): _context = self.endpoint.server_get("endpoint_context") + + #TODO: also check if the (valid) subject_token matches subject_token_type + if request["subject_token_type"] not in self.token_types_allowed: + return TokenErrorResponse( + error="invalid_request", + error_description="Unsupported subject token type", + ) + if "resource" in request: iss = urlparse(_context.issuer) if any( @@ -446,9 +457,13 @@ def check_for_errors(self, request): ) if "audience" in request: - if any( - aud != _context.issuer for aud in request["audience"] - ): + 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" + ) + _token_usage_rules = _context.authz.usage_rules(request["client_id"]) + audience = _token_usage_rules["refresh_token"].get("audience", {}) + if (not len(set(request["audience"]).intersection(set(audience)))): return TokenErrorResponse( error="invalid_target", error_description="Unknown audience" ) @@ -468,21 +483,18 @@ def check_for_errors(self, request): error="invalid_request", error_description="Actor token not supported" ) - # TODO: also check if the (valid) subject_token matches subject_token_type - if request["subject_token_type"] not in self.token_types_allowed: - return TokenErrorResponse( - error="invalid_request", - error_description="Unsupported subject token type", - ) - - def token_exchange_response(self, token): - response_args = { - "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", - "token_type": token.token_type, - "access_token": token.value, - "scope": token.scope, - "expires_in": token.usage_rules["expires_in"] - } + def token_exchange_response(self, access_token, refresh_token=None): + response_args = {} + response_args["access_token"] = access_token.value + response_args["scope"] = access_token.scope + if refresh_token is None: + response_args["issued_token_type"] = "urn:ietf:params:oauth:token-type:access_token" + response_args["token_type"] = access_token.token_type + response_args["expires_in"] = access_token.usage_rules["expires_in"] + else: + response_args["issued_token_type"] = "urn:ietf:params:oauth:token-type:refresh_token" + response_args["refresh_token"] = refresh_token.value + response_args["expires_in"] = refresh_token.usage_rules["expires_in"] return TokenExchangeResponse(**response_args) def process_request(self, request, **kwargs): @@ -516,7 +528,7 @@ def process_request(self, request, **kwargs): token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) - if not isinstance(token, AccessToken): + if not isinstance(token, (AccessToken, RefreshToken)): return self.error_cls( error="invalid_request", error_description="Wrong token type" ) @@ -538,27 +550,96 @@ def process_request(self, request, **kwargs): error_description="Unauthorized scope requested", ) - try: - new_token = self._mint_token( - token_class=token.token_class, - grant=grant, - session_id=_session_info["session_id"], + _requested_token_type = request.get("requested_token_type", + "urn:ietf:params:oauth:token-type:access_token") + if ( + _requested_token_type == "urn:ietf:params:oauth:token-type:access_token" + or _requested_token_type == "urn:ietf:params:oauth:token-type:id_token" + ): + _token_class = _requested_token_type.split(":")[-1] + _token_type = token.token_type + + try: + new_token = self._mint_token( + token_class=_token_class, + grant=grant, + session_id=_session_info["session_id"], + 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(access_token=new_token) + + elif _requested_token_type == "urn:ietf:params:oauth:token-type:refresh_token": + _token_class = "refresh_token" + _token_type = None + authn_event = create_authn_event(_session_info["user_id"]) + _token_usage_rules = _context.authz.usage_rules(request["client_id"]) + _exp_in = _token_usage_rules["refresh_token"].get("expires_in") + if _exp_in and "valid_until" in authn_event: + authn_event["valid_until"] = utc_time_sans_frac() + _exp_in + + sid = _mngr.create_session( + authn_event=authn_event, + auth_req=request, + user_id=_session_info["user_id"], client_id=request["client_id"], - based_on=token, - scope=request.get("scope"), - token_args={ - "resources":request.get("resource"), - }, - token_type=token.token_type - ) - except MintingNotAllowed: - logger.error("Minting not allowed for 'access_token'") - return self.error_cls( - error="invalid_grant", - error_description="Token Exchange not allowed with that token", + token_usage_rules=_token_usage_rules, ) - return self.token_exchange_response(token=new_token) + try: + new_token = self._mint_token( + token_class="access_token", + grant=_mngr.get_grant(sid), + 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("Minting not allowed for 'access_token'") + return self.error_cls( + error="invalid_grant", + error_description="Token Exchange not allowed with that token", + ) + + try: + refresh_token = self._mint_token( + token_class=_token_class, + grant=_mngr.get_grant(sid), + 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("Minting not allowed for 'refresh_token'") + return self.error_cls( + error="invalid_grant", + error_description="Token Exchange not allowed with that token", + ) + + return self.token_exchange_response(access_token=new_token, + refresh_token=refresh_token) class Token(oauth2.token.Token): request_cls = Message diff --git a/tests/test_36_oauth2_token_exchange.py b/tests/test_36_oauth2_token_exchange.py index 36dfe2a0..a2e877cf 100644 --- a/tests/test_36_oauth2_token_exchange.py +++ b/tests/test_36_oauth2_token_exchange.py @@ -5,6 +5,7 @@ from oidcmsg.oauth2 import TokenExchangeRequest from oidcmsg.oidc import AccessTokenRequest from oidcmsg.oidc import AuthorizationRequest +from oidcmsg.oidc import RefreshAccessTokenRequest import pytest @@ -49,6 +50,7 @@ "authorization_code", "implicit", "urn:ietf:params:oauth:grant-type:jwt-bearer", + "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token", ], } @@ -69,6 +71,10 @@ client_secret="hemligt", ) +REFRESH_TOKEN_REQ = RefreshAccessTokenRequest( + grant_type="refresh_token", client_id="https://example2.com/", client_secret="hemligt" +) + TOKEN_REQ_DICT = TOKEN_REQ.to_dict() BASEDIR = os.path.abspath(os.path.dirname(__file__)) @@ -109,14 +115,6 @@ def create_endpoint(self): "client_secret_jwt", "private_key_jwt", ], - "grant_types_supported": { - "urn:ietf:params:oauth:grant-type:token-exchange": { - "class": "oidcop.oidc.token.TokenExchangeHelper" - }, - "authorization_code": { - "class": "oidcop.oidc.token.AccessTokenHelper" - } - }, }, }, "introspection": { @@ -150,6 +148,8 @@ 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, @@ -181,7 +181,15 @@ 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"], + "allowed_scopes": ["openid", "profile", "offline_access"], + } + self.endpoint_context.cdb["https://example2.com/"] = { + "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.keyjar.import_jwks(CLIENT_KEYJAR.export_jwks(), "client_1") self.endpoint = server.server_get("endpoint", "token") @@ -305,7 +313,6 @@ def test_additional_parameters(self): }, ) _resp = self.endpoint.process_request(request=_req) - assert set(_resp["response_args"].keys()) == { 'access_token', 'token_type', 'expires_in', 'issued_token_type', 'scope' } @@ -408,8 +415,8 @@ def test_wrong_audience(self): """ Test that requesting a token for an unknown audience fails. - We currently only allow audience that match the issuer. - TODO: Should we do this? + We currently only allow audience that matches the owner of the subject_token or + the allowed audience as configured in authz/grant_config """ areq = AUTH_REQ.copy() @@ -449,6 +456,59 @@ def test_wrong_audience(self): assert _resp["error"] == "invalid_target" assert _resp["error_description"] == "Unknown audience" + @pytest.mark.parametrize("aud", [ + "https://example.com/", + ]) + def test_exchanged_refresh_token_wrong_audience(self, aud): + """ + Test that requesting a token for an unknown audience fails. + + We currently only allow audience that matches the owner of the subject_token or + the allowed audience as configured in authz/grant_config + """ + 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']) + + _cntx = self.endpoint_context + + _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"] + _session_info = self.session_manager.get_session_info_by_token(_token_value) + _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + + 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", + audience=aud + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + { + "headers": { + "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") + } + }, + ) + _resp = self.endpoint.process_request(request=_req) + + _request = REFRESH_TOKEN_REQ.copy() + _request["refresh_token"] = _resp["response_args"]["refresh_token"] + _req = self.endpoint.parse_request(_request.to_json()) + _resp = self.endpoint.process_request(request=_req) + assert set(_resp.keys()) == {"error", "error_description"} + assert _resp["error"] == "invalid_grant" + assert _resp["error_description"] == "Wrong client" + @pytest.mark.parametrize("missing_attribute", [ "subject_token_type", "subject_token", @@ -502,7 +562,6 @@ def test_missing_parameters(self, missing_attribute): @pytest.mark.parametrize("unsupported_type", [ "unknown", - "urn:ietf:params:oauth:token-type:refresh_token", "urn:ietf:params:oauth:token-type:id_token", "urn:ietf:params:oauth:token-type:saml2", "urn:ietf:params:oauth:token-type:saml1", @@ -555,7 +614,6 @@ def test_unsupported_requested_token_type(self, unsupported_type): @pytest.mark.parametrize("unsupported_type", [ "unknown", - "urn:ietf:params:oauth:token-type:refresh_token", "urn:ietf:params:oauth:token-type:id_token", "urn:ietf:params:oauth:token-type:saml2", "urn:ietf:params:oauth:token-type:saml1", @@ -691,3 +749,4 @@ def test_invalid_token(self): _resp["error_description"] == "Subject token invalid" ) + From 10ed5ac8648f20f25192426d9a4fe649d8997fa2 Mon Sep 17 00:00:00 2001 From: Kostis Triantafyllakis Date: Thu, 23 Dec 2021 12:44:57 +0200 Subject: [PATCH 6/8] Refactor token exchange configuration --- docs/source/contents/conf.rst | 114 +++++++++ docs/source/contents/usage.md | 8 +- src/oidcop/configure.py | 2 +- src/oidcop/oidc/token.py | 330 ++++++++++++------------- tests/test_36_oauth2_token_exchange.py | 277 ++++++++++++++------- 5 files changed, 464 insertions(+), 267 deletions(-) diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst index d258cd3a..423f3f77 100644 --- a/docs/source/contents/conf.rst +++ b/docs/source/contents/conf.rst @@ -663,6 +663,120 @@ 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 must 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. + +``` +"grant_types_supported":{ + "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"] + } + } + } + } + } +} +``` + +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 64827b31..c1d49a3a 100644 --- a/docs/source/contents/usage.md +++ b/docs/source/contents/usage.md @@ -133,9 +133,9 @@ Here an example about how to exchange an access token for a new access token. import requests - CLIENT_ID = "DBP60x3KUQfCYWZlqFaS_Q" - CLIENT_SECRET="8526270403788522b2444e87ea90c53bcafb984119cec92eeccc12f1" - SUBJECT_TOKEN="Z0FBQUFkF3czZRU...BfdTJkQXlCSm55cVpxQ1A0Y0RkWEtQTT0=" + CLIENT_ID="" + CLIENT_SECRET="" + SUBJECT_TOKEN="" REQUESTED_TOKEN_TYPE="urn:ietf:params:oauth:token-type:access_token" data = { @@ -147,7 +147,7 @@ Here an example about how to exchange an access token for a new access token. } headers = {'Content-Type': "application/x-www-form-urlencoded" } response = requests.post( - 'https://snf-19725.ok-kno.grnetcloud.net/OIDC/token', verify=False, data=data, headers=headers + 'https://example.com/OIDC/token', verify=False, data=data, headers=headers ) oidc-op will return a json response like this:: 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/oidc/token.py b/src/oidcop/oidc/token.py index d5a8fd97..7e711185 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -25,6 +25,7 @@ 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__) @@ -368,28 +369,41 @@ class TokenExchangeHelper(TokenEndpointHelper): def __init__(self, endpoint, config=None): TokenEndpointHelper.__init__(self, endpoint=endpoint, config=config) - # TODO: should we even have a policy for the simple use cases? if config is None: - self.policy = {} + 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": "oidcop.oidc.token.default_token_exchange_policy", + "kwargs": { + "scope": ["openid"] + } + } + } + } else: - self.policy = config.get('policy', {}) + self.config = config - # TODO: Make this a part of the policy. Note the distinction between - # requested_token_type, subject_token_type, actor_token_type, issued_token_type - self.token_types_allowed = [ - "urn:ietf:params:oauth:token-type:access_token", - "urn:ietf:params:oauth:token-type:jwt", - # "urn:ietf:params:oauth:token-type:id_token", - "urn:ietf:params:oauth:token-type:refresh_token", - ] + 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()) - if "client_id" not in request: - request["client_id"] = client_id - _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 @@ -408,10 +422,6 @@ def post_parse_request(self, request, client_id="", **kwargs): error="invalid_request", error_description="%s" % err ) - error = self.check_for_errors(request=request) - if error is not None: - return error - _mngr = _context.session_manager try: _session_info = _mngr.get_session_info_by_token( @@ -425,88 +435,92 @@ def post_parse_request(self, request, client_id="", **kwargs): ) token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) - - if not isinstance(token, (AccessToken, RefreshToken)): - return self.error_cls( - error="invalid_request", error_description="Wrong token type" - ) - if token.is_active() is False: return self.error_cls( error="invalid_request", error_description="Subject token inactive" ) - return request - def check_for_errors(self, request): + resp = self._enforce_policy(request, token, config) + + return resp + + def _enforce_policy(self, request, token, config): _context = self.endpoint.server_get("endpoint_context") - #TODO: also check if the (valid) subject_token matches subject_token_type - if request["subject_token_type"] not in self.token_types_allowed: + 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 "resource" in request: - iss = urlparse(_context.issuer) - if any( - urlparse(res).netloc != iss.netloc for res in request["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" - ) - _token_usage_rules = _context.authz.usage_rules(request["client_id"]) - audience = _token_usage_rules["refresh_token"].get("audience", {}) - if (not len(set(request["audience"]).intersection(set(audience)))): - return TokenErrorResponse( - error="invalid_target", error_description="Unknown audience" - ) - - # TODO: if requested type is jwt make sure our tokens are jwt if ( "requested_token_type" in request - and request["requested_token_type"] not in self.token_types_allowed + and request["requested_token_type"] not in config["requested_token_types_supported"] ): return TokenErrorResponse( - error="invalid_target", - error_description="Unsupported requested token type" + error="invalid_request", + error_description="Unsupported requested token type", ) - if "actor_token" in request or "actor_token_type" in request: - return TokenErrorResponse( - error="invalid_request", error_description="Actor token not supported" + 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: + if ( + "requested_token_type" in request + and request["requested_token_type"] in config["policy"] + ): + callable = config["policy"][request["requested_token_type"]]["callable"] + kwargs = config["policy"][request["requested_token_type"]]["kwargs"] + else: + callable = config["policy"][""]["callable"] + kwargs = config["policy"][""]["kwargs"] + + fn = importer(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, access_token, refresh_token=None): + def token_exchange_response(self, token): response_args = {} - response_args["access_token"] = access_token.value - response_args["scope"] = access_token.scope - if refresh_token is None: - response_args["issued_token_type"] = "urn:ietf:params:oauth:token-type:access_token" - response_args["token_type"] = access_token.token_type - response_args["expires_in"] = access_token.usage_rules["expires_in"] - else: - response_args["issued_token_type"] = "urn:ietf:params:oauth:token-type:refresh_token" - response_args["refresh_token"] = refresh_token.value - response_args["expires_in"] = refresh_token.usage_rules["expires_in"] + 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["expires_in"] + response_args["token_type"] = "bearer" + return TokenExchangeResponse(**response_args) def process_request(self, request, **kwargs): - # TODO: should we even have a policy for the simple use cases? - # client_policy = self.policy.get(req["client_id"]) or self.policy.get("default") - # if not client_policy: - # logger.error( - # "TokenExchange policy for client {req['client_id']} or default missing." - # ) - # return TokenErrorResponse( - # error="invalid_request", error_description="Not allowed" - # ) _context = self.endpoint.server_get("endpoint_context") _mngr = _context.session_manager try: @@ -528,63 +542,20 @@ def process_request(self, request, **kwargs): token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) - if not isinstance(token, (AccessToken, RefreshToken)): - return self.error_cls( - error="invalid_request", error_description="Wrong token type" - ) - - if token.is_active() is False: - return self.error_cls( - error="invalid_request", error_description="Subject token inactive" - ) - grant = _session_info["grant"] - 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", - ) - _requested_token_type = request.get("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") - if ( - _requested_token_type == "urn:ietf:params:oauth:token-type:access_token" - or _requested_token_type == "urn:ietf:params:oauth:token-type:id_token" - ): - _token_class = _requested_token_type.split(":")[-1] - _token_type = token.token_type - - try: - new_token = self._mint_token( - token_class=_token_class, - grant=grant, - session_id=_session_info["session_id"], - 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(access_token=new_token) - elif _requested_token_type == "urn:ietf:params:oauth:token-type:refresh_token": - _token_class = "refresh_token" + _token_class = _requested_token_type.split(":")[-1] + if _token_class == "access_token": + _token_type = _token_class + else: _token_type = None - authn_event = create_authn_event(_session_info["user_id"]) + + sid = _session_info["session_id"] + if request["client_id"] != _session_info["client_id"]: + authn_event = _session_info["grant"].authentication_event _token_usage_rules = _context.authz.usage_rules(request["client_id"]) _exp_in = _token_usage_rules["refresh_token"].get("expires_in") if _exp_in and "valid_until" in authn_event: @@ -598,48 +569,75 @@ def process_request(self, request, **kwargs): token_usage_rules=_token_usage_rules, ) - try: - new_token = self._mint_token( - token_class="access_token", - grant=_mngr.get_grant(sid), - 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("Minting not allowed for 'access_token'") - return self.error_cls( - error="invalid_grant", - error_description="Token Exchange not allowed with that token", + try: + new_token = self._mint_token( + token_class=_token_class, + grant=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", ) - try: - refresh_token = self._mint_token( - token_class=_token_class, - grant=_mngr.get_grant(sid), - 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("Minting not allowed for 'refresh_token'") - 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" + ) - return self.token_exchange_response(access_token=new_token, - refresh_token=refresh_token) + if ( + "requested_token_type" in request + and request["requested_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token" + ): + if "offline_access" not in subject_token.scope: + return TokenErrorResponse( + error="invalid_request", + error_description=f"Exchange {request['subject_token_type']} to refresh token forbbiden", + ) + + scopes = list(set(request.get("scope", ["openid"])).intersection(kwargs.get("scope", ["openid"]))) + if scopes: + request["scope"] = scopes + else: + return TokenErrorResponse( + error="invalid_request", + error_description="No supported scope requested", + ) + return request class Token(oauth2.token.Token): request_cls = Message diff --git a/tests/test_36_oauth2_token_exchange.py b/tests/test_36_oauth2_token_exchange.py index a2e877cf..570c95af 100644 --- a/tests/test_36_oauth2_token_exchange.py +++ b/tests/test_36_oauth2_token_exchange.py @@ -50,7 +50,6 @@ "authorization_code", "implicit", "urn:ietf:params:oauth:grant-type:jwt-bearer", - "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token", ], } @@ -72,7 +71,7 @@ ) REFRESH_TOKEN_REQ = RefreshAccessTokenRequest( - grant_type="refresh_token", client_id="https://example2.com/", client_secret="hemligt" + grant_type="refresh_token", client_id="https://example.com/", client_secret="hemligt" ) TOKEN_REQ_DICT = TOKEN_REQ.to_dict() @@ -114,7 +113,7 @@ def create_endpoint(self): "client_secret_post", "client_secret_jwt", "private_key_jwt", - ], + ] }, }, "introspection": { @@ -148,7 +147,7 @@ def create_endpoint(self): }, "refresh_token": { "supports_minting": ["access_token", "refresh_token"], - "audience": ["https://example.com/", "https://example2.com/"], + "audience": ["https://example.com", "https://example2.com"], "expires_in": 43200 }, }, @@ -181,15 +180,15 @@ 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["https://example2.com/"] = { + 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"], + "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") @@ -230,40 +229,40 @@ def _mint_code(self, grant, client_id): _code.expires_at = utc_time_sans_frac() + _exp_in return _code - def test_token_exchange(self): + @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, removing a scope. + Test that token exchange requests work correctly """ + if (list(token.keys())[0] == "refresh_token"): + AUTH_REQ["scope"] = ["openid", "offline_access"] areq = AUTH_REQ.copy() - areq["scope"] = ["openid", "profile"] + session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + _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="urn:ietf:params:oauth:token-type:access_token", - resource=["https://example.com/api"], - scope=["openid"], + 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==") + "authorization": "Basic {}".format("Y2xpZW50XzI6aGVtbGlndA==") } }, ) @@ -273,35 +272,98 @@ def test_token_exchange(self): } 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"} + ]) + 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.oidc.token.default_token_exchange_policy", + "kwargs": { + "scope": ["custom"] + } + } + } + } + + 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==") + } + }, + ) + _resp = self.endpoint.process_request(request=_req) + assert _resp["error"] == "invalid_request" + assert( + _resp["error_description"] == "No supported scope requested" + ) + def test_additional_parameters(self): """ Test that a token exchange with additional parameters including - audience and subject_token_type works. + scope, audience and subject_token_type works. """ + conf = self.endpoint.helper["urn:ietf:params:oauth:grant-type:token-exchange"].config + conf["policy"][""]["kwargs"]["audience"] = ["https://example.com"] + conf["policy"][""]["kwargs"]["resource"] = [] + 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']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) 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"], requested_token_type="urn:ietf:params:oauth:token-type:access_token", - audience=["https://example.com/"], + audience=["https://example.com"], + resource=["https://example.com"], + scope=["openid"] ) _req = self.endpoint.parse_request( @@ -334,16 +396,12 @@ def test_token_exchange_fails_if_disabled(self): grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) token_exchange_req = TokenExchangeRequest( grant_type="urn:ietf:params:oauth:grant-type:token-exchange", @@ -370,26 +428,21 @@ def test_token_exchange_fails_if_disabled(self): def test_wrong_resource(self): """ Test that requesting a token for an unknown resource fails. - - We currently only allow resources that match the issuer's host part. - TODO: Should we do this? """ + conf = self.endpoint.helper["urn:ietf:params:oauth:grant-type:token-exchange"].config + 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']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) token_exchange_req = TokenExchangeRequest( grant_type="urn:ietf:params:oauth:grant-type:token-exchange", @@ -411,36 +464,68 @@ def test_wrong_resource(self): 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() + + 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"]["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", + audience=["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.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. - - We currently only allow audience that matches the owner of the subject_token or - the allowed audience as configured in authz/grant_config """ + conf = self.endpoint.helper["urn:ietf:params:oauth:grant-type:token-exchange"].config + 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']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) 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/"], - resource=["https://example.com/api"] + audience=["https://unknown-audience.com/"] ) _req = self.endpoint.parse_request( @@ -456,39 +541,29 @@ def test_wrong_audience(self): assert _resp["error"] == "invalid_target" assert _resp["error_description"] == "Unknown audience" - @pytest.mark.parametrize("aud", [ - "https://example.com/", - ]) - def test_exchanged_refresh_token_wrong_audience(self, aud): + def test_exchange_refresh_token_to_refresh_token(self): """ - Test that requesting a token for an unknown audience fails. - - We currently only allow audience that matches the owner of the subject_token or - the allowed audience as configured in authz/grant_config + 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']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) + _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:access_token", - requested_token_type="urn:ietf:params:oauth:token-type:refresh_token", - audience=aud + 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( @@ -500,14 +575,46 @@ def test_exchanged_refresh_token_wrong_audience(self, aud): }, ) _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() - _request = REFRESH_TOKEN_REQ.copy() - _request["refresh_token"] = _resp["response_args"]["refresh_token"] - _req = self.endpoint.parse_request(_request.to_json()) + 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) - assert set(_resp.keys()) == {"error", "error_description"} - assert _resp["error"] == "invalid_grant" - assert _resp["error_description"] == "Wrong client" + _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", @@ -523,22 +630,18 @@ def test_missing_parameters(self, missing_attribute): grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) 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/"], + audience=["https://example.com"], resource=["https://example.com/api"] ) @@ -576,23 +679,19 @@ def test_unsupported_requested_token_type(self, unsupported_type): grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) 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/"], + audience=["https://example.com"], resource=["https://example.com/api"] ) @@ -606,7 +705,7 @@ def test_unsupported_requested_token_type(self, unsupported_type): ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} - assert _resp["error"] == "invalid_target" + assert _resp["error"] == "invalid_request" assert ( _resp["error_description"] == "Unsupported requested token type" @@ -628,22 +727,18 @@ def test_unsupported_subject_token_type(self, unsupported_type): grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) 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/"], + audience=["https://example.com"], resource=["https://example.com/api"] ) @@ -673,16 +768,12 @@ def test_unsupported_actor_token(self): grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) token_exchange_req = TokenExchangeRequest( grant_type="urn:ietf:params:oauth:grant-type:token-exchange", @@ -717,17 +808,11 @@ def test_invalid_token(self): grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq['client_id']) - _cntx = self.endpoint_context - _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"] - _session_info = self.session_manager.get_session_info_by_token(_token_value) - _token = self.session_manager.find_token(_session_info["session_id"], _token_value) - token_exchange_req = TokenExchangeRequest( grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token="invalid_token", From 9e032687f874cd8b37ce50bb448001b69d30b6ee Mon Sep 17 00:00:00 2001 From: Kostis Triantafyllakis Date: Tue, 18 Jan 2022 17:11:11 +0200 Subject: [PATCH 7/8] Introduce grant and session for token exchange --- docs/source/contents/conf.rst | 2 +- src/oidcop/oidc/token.py | 47 ++++++++++--------- src/oidcop/session/grant.py | 6 ++- src/oidcop/session/manager.py | 86 +++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 24 deletions(-) diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst index 423f3f77..3f18a93d 100644 --- a/docs/source/contents/conf.rst +++ b/docs/source/contents/conf.rst @@ -670,7 +670,7 @@ 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 must contain a `policy` object that describes a default +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. diff --git a/src/oidcop/oidc/token.py b/src/oidcop/oidc/token.py index 7e711185..86e1a130 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -381,7 +381,7 @@ def __init__(self, endpoint, config=None): ], "policy": { "": { - "callable": "oidcop.oidc.token.default_token_exchange_policy", + "callable": default_token_exchange_policy, "kwargs": { "scope": ["openid"] } @@ -491,17 +491,16 @@ def _enforce_policy(self, request, token, config): ) try: - if ( - "requested_token_type" in request - and request["requested_token_type"] in config["policy"] - ): - callable = config["policy"][request["requested_token_type"]]["callable"] - kwargs = config["policy"][request["requested_token_type"]]["kwargs"] + 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: - callable = config["policy"][""]["callable"] - kwargs = config["policy"][""]["kwargs"] - - fn = importer(callable) + fn = callable return fn(request, context=_context, subject_token=token, **kwargs) except Exception: @@ -515,7 +514,7 @@ def token_exchange_response(self, token): 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["expires_in"] + response_args["expires_in"] = token.usage_rules.get("expires_in", 0) response_args["token_type"] = "bearer" return TokenExchangeResponse(**response_args) @@ -541,9 +540,7 @@ def process_request(self, request, **kwargs): ) 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") @@ -555,24 +552,30 @@ def process_request(self, request, **kwargs): sid = _session_info["session_id"] if request["client_id"] != _session_info["client_id"]: - authn_event = _session_info["grant"].authentication_event _token_usage_rules = _context.authz.usage_rules(request["client_id"]) - _exp_in = _token_usage_rules["refresh_token"].get("expires_in") - if _exp_in and "valid_until" in authn_event: - authn_event["valid_until"] = utc_time_sans_frac() + _exp_in - sid = _mngr.create_session( - authn_event=authn_event, - auth_req=request, + 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=grant, + grant=_session_info["grant"], session_id=sid, client_id=request["client_id"], based_on=token, diff --git a/src/oidcop/session/grant.py b/src/oidcop/session/grant.py index 16b4eb0a..155affcb 100644 --- a/src/oidcop/session/grant.py +++ b/src/oidcop/session/grant.py @@ -449,12 +449,14 @@ def __init__( 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, + users: list = None ): Grant.__init__( self, @@ -475,3 +477,5 @@ def __init__( 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..2b17aad7 100644 --- a/src/oidcop/session/manager.py +++ b/src/oidcop/session/manager.py @@ -6,6 +6,7 @@ import uuid from oidcmsg.oauth2 import AuthorizationRequest +from oidcmsg.oauth2 import TokenExchangeRequest from oidcop import rndstr from oidcop.authn_event import AuthnEvent @@ -15,6 +16,7 @@ from oidcop.session.database import NoSuchClientSession from .database import Database from .grant import Grant +from .grant import ExchangeGrant from .grant import SessionToken from .info import ClientSessionInfo from .info import UserSessionInfo @@ -198,6 +200,41 @@ 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: + :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 + ) + 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 +284,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)) From 95eda1a8a0bbdfe767be817a91ea6cb1441155b1 Mon Sep 17 00:00:00 2001 From: Kostis Triantafyllakis Date: Wed, 19 Jan 2022 11:05:57 +0200 Subject: [PATCH 8/8] Move token exchange to oauth2 --- docs/source/contents/conf.rst | 15 +- src/oidcop/oauth2/token.py | 274 +++++++++++++++++++++- src/oidcop/oidc/token.py | 301 ++---------------------- src/oidcop/oidc/userinfo.py | 5 +- src/oidcop/session/grant.py | 13 +- src/oidcop/session/manager.py | 27 +-- tests/test_36_oauth2_token_exchange.py | 307 +++++++++++-------------- 7 files changed, 451 insertions(+), 491 deletions(-) diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst index 3f18a93d..f788cddc 100644 --- a/docs/source/contents/conf.rst +++ b/docs/source/contents/conf.rst @@ -670,14 +670,21 @@ There are two possible ways to configure Token Exchange in OIDC-OP, globally and For the first case the configuration is passed in the Token Exchange handler throught the `urn:ietf:params:oauth:grant-type:token-exchange` dictionary in token's `grant_types_supported`. -If present, the token exchange configuration may contain a `policy` object that describes a default -policy `callable` and its `kwargs` through the `""` key. Different callables can be optionally -defined for each token type supported. +If present, the token exchange configuration may contain a `policy` dictionary +that defines the behaviour for each subject token type. Each subject token type +is mapped to a dictionary with the keys `callable` (mandatory), which must be a +python callable or a string that represents the path to a python callable, and +`kwargs` (optional), which must be a dict of key-value arguments that will be +passed to the callable. + +The key `""` represents a fallback policy that will be used if the subject token +type can't be found. If a subject token type is defined in the `policy` but is +not in the `subject_token_types_supported` list then it is ignored. ``` "grant_types_supported":{ "urn:ietf:params:oauth:grant-type:token-exchange": { - "class": "oidcop.oidc.token.TokenExchangeHelper", + "class": "oidcop.oauth2.token.TokenExchangeHelper", "kwargs": { "subject_token_types_supported": [ "urn:ietf:params:oauth:token-type:access_token", diff --git a/src/oidcop/oauth2/token.py b/src/oidcop/oauth2/token.py index d08d07b9..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 @@ -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 86e1a130..9aedda49 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,14 @@ def post_parse_request( return request -class TokenExchangeHelper(TokenEndpointHelper): - """Implements Token Exchange a.k.a. RFC8693""" - - def __init__(self, endpoint, config=None): - TokenEndpointHelper.__init__(self, endpoint=endpoint, config=config) - - if config is None: - self.config = { - "subject_token_types_supported": [ - "urn:ietf:params:oauth:token-type:access_token", - "urn:ietf:params:oauth:token-type:refresh_token", - ], - "requested_token_types_supported": [ - "urn:ietf:params:oauth:token-type:access_token", - "urn:ietf:params:oauth:token-type:refresh_token", - ], - "policy": { - "": { - "callable": default_token_exchange_policy, - "kwargs": { - "scope": ["openid"] - } - } - } - } - else: - self.config = config - - self.total_subject_token_types_supported = { - "urn:ietf:params:oauth:token-type:access_token": "access_token", - "urn:ietf:params:oauth:token-type:refresh_token": "refresh_token" - } - - def post_parse_request(self, request, client_id="", **kwargs): - request = TokenExchangeRequest(**request.to_dict()) - - _context = self.endpoint.server_get("endpoint_context") - if "token_exchange" in _context.cdb[request["client_id"]]: - config = _context.cdb[request["client_id"]]["token_exchange"] - else: - config = self.config - - try: - keyjar = _context.keyjar - except AttributeError: - keyjar = "" - - try: - request.verify(keyjar=keyjar, opponent_id=client_id) - except ( - MissingRequiredAttribute, - ValueError, - MissingRequiredValue, - JWKESTException, - ) as err: - return self.endpoint.error_cls( - error="invalid_request", error_description="%s" % err - ) - - _mngr = _context.session_manager - try: - _session_info = _mngr.get_session_info_by_token( - request["subject_token"], grant=True - ) - except (KeyError, UnknownToken): - logger.error("Subject token invalid.") - return self.error_cls( - error="invalid_request", - error_description="Subject token invalid" - ) - - token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) - if token.is_active() is False: - return self.error_cls( - error="invalid_request", error_description="Subject token inactive" - ) - - resp = self._enforce_policy(request, token, config) - - return resp - - def _enforce_policy(self, request, token, config): - _context = self.endpoint.server_get("endpoint_context") - - subject_token_types_supported = ( - self.total_subject_token_types_supported.keys() - & config.get("subject_token_types_supported", "urn:ietf:params:oauth:token-type:access_token") - ) - subject_token_types_supported = { - k:self.total_subject_token_types_supported[k] for k in subject_token_types_supported - } - - if ( - request["subject_token_type"] in subject_token_types_supported - and ( - subject_token_types_supported[request["subject_token_type"]] != token.token_class - ) - ): - return self.error_cls( - error="invalid_request", error_description="Wrong token type" - ) - - if request["subject_token_type"] not in subject_token_types_supported: - return TokenErrorResponse( - error="invalid_request", - error_description="Unsupported subject token type", - ) - - if ( - "requested_token_type" in request - and request["requested_token_type"] not in config["requested_token_types_supported"] - ): - return TokenErrorResponse( - error="invalid_request", - error_description="Unsupported requested token type", - ) - - request_info = dict(scope=request.get("scope", [])) - try: - check_unknown_scopes_policy(request_info, request["client_id"], _context) - except UnAuthorizedClientScope: - logger.error("Unauthorized scope requested.") - return self.error_cls( - error="invalid_grant", - error_description="Unauthorized scope requested", - ) - - try: - subject_token_type = request.get("subject_token_type", "") - if subject_token_type not in config["policy"]: - subject_token_type = "" - callable = config["policy"][subject_token_type]["callable"] - kwargs = config["policy"][subject_token_type]["kwargs"] - - if isinstance(callable, str): - fn = importer(callable) - else: - fn = callable - return fn(request, context=_context, subject_token=token, **kwargs) - - except Exception: - return self.error_cls( - error="server_error", - error_description="Internal server error" - ) - - def token_exchange_response(self, token): - response_args = {} - response_args["access_token"] = token.value - response_args["scope"] = token.scope - response_args["issued_token_type"] = token.token_class - response_args["expires_in"] = token.usage_rules.get("expires_in", 0) - response_args["token_type"] = "bearer" - - return TokenExchangeResponse(**response_args) - - def process_request(self, request, **kwargs): - _context = self.endpoint.server_get("endpoint_context") - _mngr = _context.session_manager - try: - _session_info = _mngr.get_session_info_by_token( - request["subject_token"], grant=True - ) - except ToOld: - logger.error("Subject token has expired.") - return self.error_cls( - error="invalid_request", - error_description="Subject token has expired" - ) - except (KeyError, UnknownToken): - logger.error("Subject token invalid.") - return self.error_cls( - error="invalid_request", - error_description="Subject token invalid" - ) - - token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) - grant = _session_info["grant"] - _requested_token_type = request.get("requested_token_type", - "urn:ietf:params:oauth:token-type:access_token") - - _token_class = _requested_token_type.split(":")[-1] - if _token_class == "access_token": - _token_type = _token_class - else: - _token_type = None - - sid = _session_info["session_id"] - if request["client_id"] != _session_info["client_id"]: - _token_usage_rules = _context.authz.usage_rules(request["client_id"]) - - sid = _mngr.create_exchange_session( - exchange_request=request, - original_session_id=sid, - user_id=_session_info["user_id"], - client_id=request["client_id"], - token_usage_rules=_token_usage_rules, - ) - - try: - _session_info = _mngr.get_session_info( - session_id=sid, grant=True) - except Exception: - logger.error("Error retrieving token exchabge session information") - return self.error_cls( - error="server_error", - error_description="Internal server error" - ) - - try: - new_token = self._mint_token( - token_class=_token_class, - grant=_session_info["grant"], - session_id=sid, - client_id=request["client_id"], - based_on=token, - scope=request.get("scope"), - token_args={ - "resources":request.get("resource"), - }, - token_type=_token_type - ) - except MintingNotAllowed: - logger.error(f"Minting not allowed for {_token_class}") - return self.error_cls( - error="invalid_grant", - error_description="Token Exchange not allowed with that token", - ) - return self.token_exchange_response(token=new_token) +class TokenExchangeHelper(OAuth2TokenExchangeHelper): -def default_token_exchange_policy(request, context, subject_token, **kwargs): - if "resource" in request: - resource = kwargs.get("resource", []) - if not resource: - pass - elif (not len(set(request["resource"]).intersection(set(resource)))): - return TokenErrorResponse( - error="invalid_target", error_description="Unknown resource" - ) - - if "audience" in request: - if request["subject_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token": - return TokenErrorResponse( - error="invalid_target", error_description="Refresh token has single owner" - ) - audience = kwargs.get("audience", []) - if not audience: - pass - elif (audience and not len(set(request["audience"]).intersection(set(audience)))): - return TokenErrorResponse( - error="invalid_target", error_description="Unknown audience" - ) - - if "actor_token" in request or "actor_token_type" in request: - return TokenErrorResponse( - error="invalid_request", error_description="Actor token not supported" - ) - - if ( - "requested_token_type" in request - and request["requested_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token" - ): - if "offline_access" not in subject_token.scope: - return TokenErrorResponse( - error="invalid_request", - error_description=f"Exchange {request['subject_token_type']} to refresh token forbbiden", - ) - - scopes = list(set(request.get("scope", ["openid"])).intersection(kwargs.get("scope", ["openid"]))) - if scopes: - request["scope"] = scopes - else: - return TokenErrorResponse( - error="invalid_request", - error_description="No supported scope requested", - ) - return request + 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 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..c57d2a17 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,11 @@ def __init__( 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, @@ -471,11 +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 + 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..06c2a8f0 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,15 @@ 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( + 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, - scope=scopes, - claims=_claims, - original_session_id=original_session_id, - exchange_request=exchange_request ) self.set([user_id, client_id, grant.id], grant) @@ -325,7 +322,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..6731ee10 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): """ @@ -340,14 +341,15 @@ def test_additional_parameters(self): 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"] = [] + 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 +365,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 +387,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 +406,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" ) @@ -430,12 +425,13 @@ 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']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -448,16 +444,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 +465,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 +478,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"} @@ -507,12 +495,13 @@ 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']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -525,16 +514,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 +535,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 +583,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 +596,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 +611,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 +625,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 +656,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 +671,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 +700,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 +714,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 +734,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 +747,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 +767,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 +782,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"