diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst index 483a7d4c..92e97711 100644 --- a/docs/source/contents/conf.rst +++ b/docs/source/contents/conf.rst @@ -51,7 +51,36 @@ Optional. Salt, value or filename, used in sub_funcs (pairwise, public) for crea sub_funcs ######### -Optional. Functions involved in *sub*ject value creation. +Optional. Functions involved in subject value creation. + + +scopes_to_claims +################ + +A dict defining the scopes that are allowed to be used per client and the claims +they map to (defaults to the scopes mapping described in the spec). If we want +to define a scope that doesn't map to claims (e.g. offline_access) then we +simply map it to an empty list. E.g.:: + + { + "scope_a": ["claim1", "claim2"], + "scope_b": [] + } + +*Note*: For OIDC the `openid` scope must be present in this mapping. + + +allowed_scopes +############## + +A list with the scopes that are allowed to be used (defaults to the keys in scopes_to_claims). + + +scopes_supported +################ + +A list with the scopes that will be advertised in the well-known endpoint (defaults to allowed_scopes). + ------ add_on @@ -67,21 +96,6 @@ An example:: "code_challenge_method": "S256 S384 S512" } }, - "claims": { - "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes", - "kwargs": { - "research_and_scholarship": [ - "name", - "given_name", - "family_name", - "email", - "email_verified", - "sub", - "iss", - "eduperson_scoped_affiliation" - ] - } - } } The provided add-ons can be seen in the following sections. @@ -176,6 +190,8 @@ An example:: backchannel_logout_supported: True backchannel_logout_session_supported: True check_session_iframe: https://127.0.0.1:5000/check_session_iframe + scopes_supported: ["openid", "profile", "random"] + claims_supported: ["sub", "given_name", "birthdate"] --------- client_db @@ -325,8 +341,9 @@ An example:: "client_secret_post", "client_secret_basic", "client_secret_jwt", - "private_key_jwt" - ] + "private_key_jwt", + ], + "revoke_refresh_on_issue": True } }, "userinfo": { @@ -649,57 +666,14 @@ the following:: Clients ======= -In this section there are some client configuration examples. - -A common configuration:: - - endpoint_context.cdb['jbxedfmfyc'] = { - client_id: 'jbxedfmfyc', - client_salt: '6flfsj0Z', - registration_access_token: 'z3PCMmC1HZ1QmXeXGOQMJpWQNQynM4xY', - registration_client_uri: 'https://127.0.0.1:8000/registration_api?client_id=jbxedfmfyc', - client_id_issued_at: 1630256902, - client_secret: '19cc69b70d0108f630e52f72f7a3bd37ba4e11678ad1a7434e9818e1', - client_secret_expires_at: 1929727754, - application_type: 'web', - contacts: [ - 'rp@example.com' - ], - token_endpoint_auth_method: 'client_secret_basic', - redirect_uris: [ - [ - 'https://127.0.0.1:8090/authz_cb/satosa', - {} - ] - ], - post_logout_redirect_uris: [ - [ - 'https://127.0.0.1:8090/session_logout/satosa', - null - ] - ], - response_types: [ - 'code' - ], - grant_types: [ - 'authorization_code' - ], - allowed_scopes: [ - 'openid', - 'profile', - 'email', - 'offline_access' - ] - } - +In this section there are some client configuration examples. That can be used +to override the global configuration of the OP. How to configure the release of the user claims per clients:: endpoint_context.cdb["client_1"] = { "client_secret": "hemligt", "redirect_uris": [("https://example.com/cb", None)], - "client_salt": "salted", - "token_endpoint_auth_method": "client_secret_post", "response_types": ["code", "token", "code id_token", "id_token"], "add_claims": { "always": { @@ -712,3 +686,253 @@ How to configure the release of the user claims per clients:: "id_token": False, }, }, + +The available configuration options are: + +------------- +client_secret +------------- + +The client secret. This parameter is required. + +------------------------ +client_secret_expires_at +------------------------ + +When the client_secret expires. + +------------- +redirect_uris +------------- + +The client's redirect uris. + +----------- +auth_method +----------- + +The auth_method that can be used per endpoint. +E.g:: + + { + "AccessTokenRequest": "client_secret_basic", + ... + } + +------------ +request_uris +------------ + +A list of `request_uris`. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata. + +-------------- +response_types +-------------- + +The allowed `response_types` for this client. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata. + +--------------------- +grant_types_supported +--------------------- + +Configure the allowed grant types on the token endpoint. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata. + +---------------- +scopes_to_claims +---------------- + +A dict defining the scopes that are allowed to be used per client and the claims +they map to (defaults to the scopes mapping described in the spec). If we want +to define a scope that doesn't map to claims (e.g. offline_access) then we +simply map it to an empty list. E.g.:: + + { + "scope_a": ["claim1", "claim2"], + "scope_b": [] + } + +-------------- +allowed_scopes +-------------- + +A list with the scopes that are allowed to be used (defaults to the keys in the +clients scopes_to_claims). + +----------------------- +revoke_refresh_on_issue +----------------------- + +Configure whether to revoke the refresh token that was used to issue a new refresh token. + +---------- +add_claims +---------- + +A dictionary with the following keys + +always +###### + +A dictionary with the following keys: `userinfo`, `id_token`, `introspection`, `access_token`. +The keys are used to describe the claims we want to add to the corresponding interface. +The keys can be a list of claims to be added or a dict in the format described +in https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests +E.g.:: + + { + "add_claims": { + "always": { + "userinfo": ["email", "phone"], # Always add "email" and "phone" in the userinfo response if such claims exists + "id_token": {"email": null}, # Always add "email" in the id_token if such a claim exists + "introspection": {"email": {"value": "a@a.com"}}, # Add "email" in the introspection response only if its value is "a@a.com" + } + } + } + +by_scope +######## + +A dictionary with the following keys: `userinfo`, `id_token`, `introspection`, `access_token`. +The keys are boolean values that describe whether the scopes should be mapped +to claims and added to the response. +E.g.:: + + { + "add_claims": { + "by_scope": { + id_token: True, # Map the requested scopes to claims and add them to the id token + } + +----------------- +token_usage_rules +----------------- + +The usage rules for each token type. E.g.:: + + { + "usage_rules": { + "authorization_code": { + "expires_in": 3600, + "supports_minting": [ + "access_token", + "id_token", + ], + "max_usage": 1, + }, + "access_token": { + "expires_in": self.params["access_token_lifetime"], + }, + } + } + +-------------- +pkce_essential +-------------- + +Whether pkce is essential for this client. + +------------------------ +post_logout_redirect_uri +------------------------ + +The client's post logout redirect uris. + +See https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout. + +---------------------- +backchannel_logout_uri +---------------------- + +The client's `backchannel_logout_uri`. + +See https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRegistration + +----------------------- +frontchannel_logout_uri +----------------------- + +The client's `frontchannel_logout_uri`. + +See https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPLogout + +-------------------------- +request_object_signing_alg +-------------------------- + +A list with the allowed algorithms for signing the request object. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +----------------------------- +request_object_encryption_alg +----------------------------- + +A list with the allowed alg algorithms for encrypting the request object. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +----------------------------- +request_object_encryption_enc +----------------------------- + +A list with the allowed enc algorithms for signing the request object. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +---------------------------- +userinfo_signed_response_alg +---------------------------- + +JWS alg algorithm [JWA] REQUIRED for signing UserInfo Responses. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +------------------------------- +userinfo_encrypted_response_enc +------------------------------- + +The alg algorithm [JWA] REQUIRED for encrypting UserInfo Responses. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +------------------------------- +userinfo_encrypted_response_alg +------------------------------- + +JWE enc algorithm [JWA] REQUIRED for encrypting UserInfo Responses. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +---------------------------- +id_token_signed_response_alg +---------------------------- + +JWS alg algorithm [JWA] REQUIRED for signing ID Token issued to this Client. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +------------------------------- +id_token_encrypted_response_enc +------------------------------- + +The alg algorithm [JWA] REQUIRED for encrypting ID Token issued to this Client. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +------------------------------- +id_token_encrypted_response_alg +------------------------------- + +JWE enc algorithm [JWA] REQUIRED for encrypting ID Token issued to this Client. + +See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetadata + +-------- +dpop_jkt +-------- diff --git a/example/flask_op/views.py b/example/flask_op/views.py index a10d41fc..d17b7347 100644 --- a/example/flask_op/views.py +++ b/example/flask_op/views.py @@ -78,14 +78,16 @@ def do_response(endpoint, req_args, error='', **args): if error: if _response_placement == 'body': _log.info('Error Response: {}'.format(info['response'])) - resp = make_response(info['response'], 400) + _http_response_code = info.get('response_code', 400) + resp = make_response(info['response'], _http_response_code) else: # _response_placement == 'url': _log.info('Redirect to: {}'.format(info['response'])) resp = redirect(info['response']) else: if _response_placement == 'body': _log.info('Response: {}'.format(info['response'])) - resp = make_response(info['response'], 200) + _http_response_code = info.get('response_code', 200) + resp = make_response(info['response'], _http_response_code) else: # _response_placement == 'url': _log.info('Redirect to: {}'.format(info['response'])) resp = redirect(info['response']) @@ -166,10 +168,14 @@ def registration(): current_app.server.server_get("endpoint", 'registration')) -@oidc_op_views.route('/registration_api', methods=['GET']) +@oidc_op_views.route('/registration_api', methods=['GET', 'DELETE']) def registration_api(): - return service_endpoint( - current_app.server.server_get("endpoint", 'registration_read')) + if request.method == "DELETE": + return service_endpoint( + current_app.server.server_get("endpoint", 'registration_delete')) + else: + return service_endpoint( + current_app.server.server_get("endpoint", 'registration_read')) @oidc_op_views.route('/authorization') @@ -245,10 +251,14 @@ def service_endpoint(endpoint): err_msg = ResponseMessage(error='invalid_request', error_description=str(err)) return make_response(err_msg.to_json(), 400) - _log.info('request: {}'.format(req_args)) if isinstance(req_args, ResponseMessage) and 'error' in req_args: - return make_response(req_args.to_json(), 400) + _log.info('Error response: {}'.format(req_args)) + _resp = make_response(req_args.to_json(), 400) + if request.method == "POST": + _resp.headers["Content-type"] = "application/json" + return _resp try: + _log.info('request: {}'.format(req_args)) if isinstance(endpoint, Token): args = endpoint.process_request(AccessTokenRequest(**req_args), http_info=http_info) else: diff --git a/src/oidcop/authz/__init__.py b/src/oidcop/authz/__init__.py index 04203cff..a22a11bb 100755 --- a/src/oidcop/authz/__init__.py +++ b/src/oidcop/authz/__init__.py @@ -80,8 +80,10 @@ def __call__( grant.resources = resources # After this is where user consent should be handled - scopes = request.get("scope", []) - grant.scope = scopes + scopes = grant.scope + if not scopes: + scopes = request.get("scope", []) + grant.scope = scopes grant.claims = self.server_get("endpoint_context").claims_interface.get_claims_all_usage( session_id=session_id, scopes=scopes ) diff --git a/src/oidcop/configure.py b/src/oidcop/configure.py index 042593fa..fb637827 100755 --- a/src/oidcop/configure.py +++ b/src/oidcop/configure.py @@ -10,6 +10,7 @@ from typing import Union from oidcop.logging import configure_logging +from oidcop.scopes import SCOPE2CLAIMS from oidcop.utils import load_yaml_config DEFAULT_FILE_ATTRIBUTE_NAMES = [ @@ -59,7 +60,10 @@ "max_usage": 1, }, "access_token": {}, - "refresh_token": {"supports_minting": ["access_token", "refresh_token"]}, + "refresh_token": { + "supports_minting": ["access_token", "refresh_token"], + "expires_in": -1 + }, }, "expires_in": 43200, } @@ -75,6 +79,7 @@ "refresh": {"class": "oidcop.token.jwt_token.JWTToken", "kwargs": {"lifetime": 86400}, }, "id_token": {"class": "oidcop.token.id_token.IDToken", "kwargs": {}}, }, + "scopes_to_claims": SCOPE2CLAIMS, } AS_DEFAULT_CONFIG = copy.deepcopy(OP_DEFAULT_CONFIG) @@ -271,12 +276,35 @@ class OPConfiguration(EntityConfiguration): "Provider configuration" default_config = OP_DEFAULT_CONFIG parameter = EntityConfiguration.parameter.copy() - parameter.update({ - "id_token": None, - "login_hint2acrs": {}, - "login_hint_lookup": None, - "sub_func": {} - }) + parameter.update( + { + "id_token": None, + "login_hint2acrs": {}, + "login_hint_lookup": None, + "sub_func": {}, + "scopes_to_claims": {}, + } + ) + + def __init__( + self, + conf: Dict, + base_path: Optional[str] = "", + entity_conf: Optional[List[dict]] = None, + domain: Optional[str] = "", + port: Optional[int] = 0, + file_attributes: Optional[List[str]] = None, + ): + super().__init__( + conf=conf, + base_path=base_path, + entity_conf=entity_conf, + domain=domain, + port=port, + file_attributes=file_attributes, + ) + scopes_to_claims = self.scopes_to_claims + class ASConfiguration(EntityConfiguration): "Authorization server configuration" @@ -380,7 +408,10 @@ def __init__( "max_usage": 1, }, "access_token": {}, - "refresh_token": {"supports_minting": ["access_token", "refresh_token"]}, + "refresh_token": { + "supports_minting": ["access_token", "refresh_token"], + "expires_in": -1 + }, }, "expires_in": 43200, } diff --git a/src/oidcop/constant.py b/src/oidcop/constant.py index c0527b74..99a0f06d 100644 --- a/src/oidcop/constant.py +++ b/src/oidcop/constant.py @@ -1 +1,3 @@ DIVIDER = ";;" + +DEFAULT_TOKEN_LIFETIME = 1800 diff --git a/src/oidcop/cookie_handler.py b/src/oidcop/cookie_handler.py index b36a0c33..7812f825 100755 --- a/src/oidcop/cookie_handler.py +++ b/src/oidcop/cookie_handler.py @@ -167,6 +167,7 @@ def _ver_dec_content(self, parts): try: msg = decrypter.decrypt(ciphertext, iv, tag=tag) except InvalidTag: + LOGGER.debug("Decryption failed") return None p = lv_unpack(msg.decode("utf-8")) @@ -180,6 +181,8 @@ def _ver_dec_content(self, parts): self.sign_key.key, ): return payload, timestamp + else: + LOGGER.debug("Could not verify signature") else: return payload, timestamp return None @@ -247,12 +250,18 @@ def parse_cookie(self, name: str, cookies: List[dict]) -> Optional[List[dict]]: if not cookies: return None + LOGGER.debug("Looking for '{}' cookies".format(name)) res = [] for _cookie in cookies: - if _cookie["name"] == name: - payload, timestamp = self._ver_dec_content(_cookie["value"].split("|")) - value, typ = payload.split("::") - res.append({"value": value, "type": typ, "timestamp": timestamp}) + LOGGER.debug('Cookie: {}'.format(_cookie)) + if "name" in _cookie and _cookie["name"] == name: + _content = self._ver_dec_content(_cookie["value"].split("|")) + if _content: + payload, timestamp = self._ver_dec_content(_cookie["value"].split("|")) + value, typ = payload.split("::") + res.append({"value": value, "type": typ, "timestamp": timestamp}) + else: + LOGGER.debug(f"Could not verify {name} cookie") return res diff --git a/src/oidcop/endpoint.py b/src/oidcop/endpoint.py index 3152cbb3..f5ea2bf8 100755 --- a/src/oidcop/endpoint.py +++ b/src/oidcop/endpoint.py @@ -128,10 +128,6 @@ def __init__(self, server_get: Callable, **kwargs): self.allowed_targets = [self.name] self.client_verification_method = [] - def parse_cookies(self, cookies: List[dict], context: EndpointContext, name: str): - res = context.cookie_handler.parse_cookie(name, cookies) - return res - def parse_request( self, request: Union[Message, dict, str], http_info: Optional[dict] = None, **kwargs ): @@ -330,10 +326,9 @@ def do_response( resp = None if error: _response = ResponseMessage(error=error) - try: - _response["error_description"] = kwargs["error_description"] - except KeyError: - pass + for attr in ["error_description", "error_uri", "state"]: + if attr in kwargs: + _response[attr] = kwargs[attr] elif "response_msg" in kwargs: resp = kwargs["response_msg"] _response_placement = kwargs.get("response_placement") @@ -405,6 +400,11 @@ def do_response( except KeyError: pass + try: + _resp["response_code"] = kwargs["response_code"] + except KeyError: + pass + return _resp def allowed_target_uris(self): diff --git a/src/oidcop/endpoint_context.py b/src/oidcop/endpoint_context.py index 9a683f2b..51629b67 100755 --- a/src/oidcop/endpoint_context.py +++ b/src/oidcop/endpoint_context.py @@ -1,6 +1,7 @@ import json import logging from typing import Any +from typing import Callable from typing import Optional from typing import Union @@ -121,6 +122,7 @@ class EndpointContext(OidcContext): def __init__( self, conf: Union[dict, OPConfiguration], + server_get: Callable, keyjar: Optional[KeyJar] = None, cwd: Optional[str] = "", cookie_handler: Optional[Any] = None, @@ -128,6 +130,7 @@ def __init__( ): OidcContext.__init__(self, conf, keyjar, entity_id=conf.get("issuer", "")) self.conf = conf + self.server_get = server_get _client_db = conf.get("client_db") if _client_db: @@ -248,10 +251,14 @@ def set_scopes_handler(self): _spec = self.conf.get("scopes_handler") if _spec: _kwargs = _spec.get("kwargs", {}) - _cls = importer(_spec["class"])(**_kwargs) - self.scopes_handler = _cls(_kwargs) + _cls = importer(_spec["class"]) + self.scopes_handler = _cls(self.server_get, **_kwargs) else: - self.scopes_handler = Scopes() + self.scopes_handler = Scopes( + self.server_get, + allowed_scopes=self.conf.get("allowed_scopes"), + scopes_to_claims=self.conf.get("scopes_to_claims"), + ) def do_add_on(self, endpoints): _add_on_conf = self.conf.get("add_on") @@ -325,8 +332,10 @@ def create_providerinfo(self, capabilities): _provider_info["jwks_uri"] = self.jwks_uri if "scopes_supported" not in _provider_info: - _provider_info["scopes_supported"] = [s for s in self.scope2claims.keys()] + _provider_info["scopes_supported"] = self.scopes_handler.get_allowed_scopes() if "claims_supported" not in _provider_info: - _provider_info["claims_supported"] = STANDARD_CLAIMS[:] + _provider_info["claims_supported"] = list( + self.scopes_handler.scopes_to_claims(_provider_info["scopes_supported"]).keys() + ) return _provider_info diff --git a/src/oidcop/oauth2/authorization.py b/src/oidcop/oauth2/authorization.py index 2a68230e..2d13ad50 100755 --- a/src/oidcop/oauth2/authorization.py +++ b/src/oidcop/oauth2/authorization.py @@ -45,9 +45,11 @@ # For the time being. This is JAR specific and should probably be configurable. ALG_PARAMS = { - "sign": ["request_object_signing_alg", "request_object_signing_alg_values_supported",], - "enc_alg": ["request_object_encryption_alg", "request_object_encryption_alg_values_supported",], - "enc_enc": ["request_object_encryption_enc", "request_object_encryption_enc_values_supported",], + "sign": ["request_object_signing_alg", "request_object_signing_alg_values_supported", ], + "enc_alg": ["request_object_encryption_alg", + "request_object_encryption_alg_values_supported", ], + "enc_enc": ["request_object_encryption_enc", + "request_object_encryption_enc_values_supported", ], } FORM_POST = """ @@ -69,6 +71,8 @@ def inputs(form_args): element = [] html_field = '' for name, value in form_args.items(): + if name == "scope" and isinstance(value, list): + value = " ".join(value) element.append(html_field.format(name, value)) return "\n".join(element) @@ -79,10 +83,10 @@ def max_age(request): def verify_uri( - endpoint_context: EndpointContext, - request: Union[dict, Message], - uri_type: str, - client_id: Optional[str] = None, + endpoint_context: EndpointContext, + request: Union[dict, Message], + uri_type: str, + client_id: Optional[str] = None, ): """ A redirect URI @@ -100,8 +104,6 @@ def verify_uri( if not _cid: logger.error("No client id found") raise UnknownClient("No client_id provided") - else: - logger.debug("Client ID: {}".format(_cid)) _uri = request.get(uri_type) if _uri is None: @@ -122,7 +124,6 @@ def verify_uri( if client_info is None: raise KeyError("No such client") - logger.debug("Client info: {}".format(client_info)) redirect_uris = client_info.get("{}s".format(uri_type)) if redirect_uris is None: raise ValueError(f"No registered {uri_type} for {_cid}") @@ -207,7 +208,7 @@ def get_uri(endpoint_context, request, uri_type): def authn_args_gather( - request: Union[AuthorizationRequest, dict], authn_class_ref: str, cinfo: dict, **kwargs, + request: Union[AuthorizationRequest, dict], authn_class_ref: str, cinfo: dict, **kwargs, ): """ Gather information to be used by the authentication method @@ -245,16 +246,20 @@ def authn_args_gather( return authn_args -def check_unknown_scopes_policy(request_info, cinfo, endpoint_context): - op_capabilities = endpoint_context.conf["capabilities"] - client_allowed_scopes = cinfo.get("allowed_scopes") or op_capabilities["scopes_supported"] +def check_unknown_scopes_policy(request_info, client_id, endpoint_context): + if not endpoint_context.conf["capabilities"].get("deny_unknown_scopes"): + return + scope = request_info["scope"] + filtered_scopes = set( + endpoint_context.scopes_handler.filter_scopes(scope, client_id=client_id) + ) + scopes = set(scope) # this prevents that authz would be released for unavailable scopes - for scope in request_info["scope"]: - if op_capabilities.get("deny_unknown_scopes") and scope not in client_allowed_scopes: - _msg = "{} requested an unauthorized scope ({})" - logger.warning(_msg.format(cinfo["client_id"], scope)) - raise UnAuthorizedClientScope() + if scopes != filtered_scopes: + diff = " ".join(scopes - filtered_scopes) + logger.warning(f"{client_id} requested unauthorized scopes: {diff}") + raise UnAuthorizedClientScope() class Authorization(Endpoint): @@ -291,6 +296,13 @@ def filter_request(self, endpoint_context, req): def extra_response_args(self, aresp): return aresp + def authentication_error_response(self, request, error, error_description, **kwargs): + _error_msg = self.error_cls(error=error, error_description=error_description) + _state = request.get("state") + if _state: + _error_msg["state"] = _state + return _error_msg + def verify_response_type(self, request: Union[Message, dict], cinfo: dict) -> bool: # Checking response types _registered = [set(rt.split(" ")) for rt in cinfo.get("response_types", [])] @@ -392,20 +404,23 @@ def _post_parse_request(self, request, client_id, endpoint_context, **kwargs): """ if not request: logger.debug("No AuthzRequest") - return self.error_cls( - error="invalid_request", error_description="Can not parse AuthzRequest" - ) + return self.authentication_error_response(request, + error="invalid_request", + error_description="Can not parse AuthzRequest" + ) request = self.filter_request(endpoint_context, request) _cinfo = endpoint_context.cdb.get(client_id) if not _cinfo: logger.error("Client ID ({}) not in client database".format(request["client_id"])) - return self.error_cls(error="unauthorized_client", error_description="unknown client") + return self.authentication_error_response(request, error="unauthorized_client", + error_description="unknown client") # Is the asked for response_type among those that are permitted if not self.verify_response_type(request, _cinfo): - return self.error_cls( + return self.authentication_error_response( + request, error="invalid_request", error_description="Trying to use unregistered response_type", ) @@ -414,7 +429,8 @@ def _post_parse_request(self, request, client_id, endpoint_context, **kwargs): try: redirect_uri = get_uri(endpoint_context, request, "redirect_uri") except (RedirectURIError, ParameterError) as err: - return self.error_cls( + return self.authentication_error_response( + request, error="invalid_request", error_description="{}:{}".format(err.__class__.__name__, err), ) @@ -452,7 +468,7 @@ def pick_authn_method(self, request, redirect_uri, acr=None, **kwargs): def create_session(self, request, user_id, acr, time_stamp, authn_method): _context = self.server_get("endpoint_context") _mngr = _context.session_manager - authn_event = create_authn_event(user_id, authn_info=acr, time_stamp=time_stamp,) + authn_event = create_authn_event(user_id, authn_info=acr, time_stamp=time_stamp, ) _exp_in = authn_method.kwargs.get("expires_in") if _exp_in and "valid_until" in authn_event: authn_event["valid_until"] = utc_time_sans_frac() + _exp_in @@ -466,14 +482,26 @@ def create_session(self, request, user_id, acr, time_stamp, authn_method): token_usage_rules=_token_usage_rules, ) + def _login_required_error(self, redirect_uri, request): + _res = { + "error": "login_required", + "return_uri": redirect_uri, + "return_type": request["response_type"], + } + _state = request.get("state") + if _state: + _res["state"] = _state + logger.debug("Login required error: {}".format(_res)) + return _res + def setup_auth( - self, - request: Optional[Union[Message, dict]], - redirect_uri: str, - cinfo: dict, - cookie: List[dict] = None, - acr: str = None, - **kwargs, + self, + request: Optional[Union[Message, dict]], + redirect_uri: str, + cinfo: dict, + cookie: List[dict] = None, + acr: str = None, + **kwargs, ): """ @@ -499,6 +527,7 @@ def setup_auth( _max_age = 0 else: _max_age = max_age(request) + logger.debug(f'Max age: {_max_age}') identity, _ts = authn.authenticated_as( client_id, cookie, authorization=_auth_info, max_age=_max_age ) @@ -541,11 +570,7 @@ def setup_auth( if "prompt" in request and "none" in request["prompt"]: # Need to authenticate but not allowed - return { - "error": "login_required", - "return_uri": redirect_uri, - "return_type": request["response_type"], - } + return self._login_required_error(redirect_uri, request) else: return {"function": authn, "args": authn_args} else: @@ -560,11 +585,7 @@ def setup_auth( if user != kwargs["req_user"]: logger.debug("Wanted to be someone else!") if "prompt" in request and "none" in request["prompt"]: - # Need to authenticate but not allowed - return { - "error": "login_required", - "return_uri": redirect_uri, - } + return self._login_required_error(redirect_uri, request) else: return {"function": authn, "args": authn_args} @@ -605,12 +626,12 @@ def aresp_check(self, aresp, request): return "" def response_mode( - self, - request: Union[dict, AuthorizationRequest], - response_args: Optional[AuthorizationResponse] = None, - return_uri: Optional[str] = "", - fragment_enc: Optional[bool] = None, - **kwargs, + self, + request: Union[dict, AuthorizationRequest], + response_args: Optional[AuthorizationResponse] = None, + return_uri: Optional[str] = "", + fragment_enc: Optional[bool] = None, + **kwargs, ) -> dict: resp_mode = request["response_mode"] if resp_mode == "form_post": @@ -618,9 +639,9 @@ def response_mode( _args = response_args.to_dict() else: _args = response_args - msg = FORM_POST.format(inputs=inputs(_args), action=return_uri,) + msg = FORM_POST.format(inputs=inputs(_args), action=return_uri, ) kwargs.update( - {"response_msg": msg, "content_type": "text/html", "response_placement": "body",} + {"response_msg": msg, "content_type": "text/html", "response_placement": "body", } ) elif resp_mode == "fragment": if fragment_enc is False: @@ -640,8 +661,10 @@ def response_mode( return kwargs - def error_response(self, response_info, error, error_description): - resp = self.error_cls(error=error, error_description=str(error_description)) + def error_response(self, response_info, request, error, error_description): + resp = self.authentication_error_response(request, + error=error, + error_description=str(error_description)) response_info["response_args"] = resp return response_info @@ -664,7 +687,9 @@ def create_authn_response(self, request: Union[dict, Message], sid: str) -> dict _sinfo = _mngr.get_session_info(sid, grant=True) if request.get("scope"): - aresp["scope"] = request["scope"] + aresp["scope"] = _context.scopes_handler.filter_scopes( + request["scope"], _sinfo["client_id"] + ) rtype = set(request["response_type"][:]) handled_response_type = [] @@ -715,7 +740,8 @@ def create_authn_response(self, request: Union[dict, Message], sid: str) -> dict # id_token = _context.idtoken.make(sid, **kwargs) except (JWEException, NoSuitableSigningKeys) as err: logger.warning(str(err)) - resp = self.error_cls( + resp = self.authentication_error_response( + request, error="invalid_request", error_description="Could not sign/encrypt id_token", ) @@ -726,7 +752,8 @@ def create_authn_response(self, request: Union[dict, Message], sid: str) -> dict not_handled = rtype.difference(handled_response_type) if not_handled: - resp = self.error_cls( + resp = self.authentication_error_response( + request, error="invalid_request", error_description="unsupported_response_type", ) return {"response_args": resp, "fragment_enc": fragment_enc} @@ -753,13 +780,14 @@ def post_authentication(self, request: Union[dict, Message], session_id: str, ** grant = _context.authz(session_id, request=request) if grant.is_active() is False: - return self.error_response(response_info, "server_error", "Grant not usable") + return self.error_response(response_info, request, "server_error", "Grant not usable") user_id, client_id, grant_id = _mngr.decrypt_session_id(session_id) try: _mngr.set([user_id, client_id, grant_id], grant) except Exception as err: - return self.error_response(response_info, "server_error", "{}".format(err.args)) + return self.error_response(response_info, request, "server_error", + "{}".format(err.args)) logger.debug("response type: %s" % request["response_type"]) @@ -771,7 +799,8 @@ def post_authentication(self, request: Union[dict, Message], session_id: str, ** try: redirect_uri = get_uri(_context, request, "redirect_uri") except (RedirectURIError, ParameterError) as err: - return self.error_response(response_info, "invalid_request", "{}".format(err.args)) + return self.error_response(response_info, request, "invalid_request", + "{}".format(err.args)) else: response_info["return_uri"] = redirect_uri @@ -783,7 +812,8 @@ def post_authentication(self, request: Union[dict, Message], session_id: str, ** try: response_info = self.response_mode(request, **response_info) except InvalidRequest as err: - return self.error_response(response_info, "invalid_request", "{}".format(err.args)) + return self.error_response(response_info, request, "invalid_request", + "{}".format(err.args)) _cookie_info = _context.new_cookie( name=_context.cookie_handler.name["session"], @@ -807,7 +837,7 @@ def authz_part2(self, request, session_id, **kwargs): try: resp_info = self.post_authentication(request, session_id, **kwargs) except Exception as err: - return self.error_response({}, "server_error", err) + return self.error_response({}, request, "server_error", err) _context = self.server_get("endpoint_context") @@ -816,10 +846,11 @@ def authz_part2(self, request, session_id, **kwargs): try: authn_event = _context.session_manager.get_authentication_event(session_id) except KeyError: - return self.error_response({}, "server_error", "No such session") + return self.error_response({}, request, "server_error", "No such session") else: if authn_event.is_valid() is False: - return self.error_response({}, "server_error", "Authentication has timed out") + return self.error_response({}, request, "server_error", + "Authentication has timed out") _state = b64e(as_bytes(json.dumps({"authn_time": authn_event["authn_time"]}))) @@ -850,8 +881,9 @@ def authz_part2(self, request, session_id, **kwargs): resp_info["response_args"]["session_state"] = _session_state # Mix-Up mitigation - resp_info["response_args"]["iss"] = _context.issuer - resp_info["response_args"]["client_id"] = request["client_id"] + if "response_args" in resp_info: + resp_info["response_args"]["iss"] = _context.issuer + resp_info["response_args"]["client_id"] = request["client_id"] return resp_info @@ -859,10 +891,10 @@ def do_request_user(self, request_info, **kwargs): return kwargs def process_request( - self, - request: Optional[Union[Message, dict]] = None, - http_info: Optional[dict] = None, - **kwargs, + self, + request: Optional[Union[Message, dict]] = None, + http_info: Optional[dict] = None, + **kwargs, ): """ The AuthorizationRequest endpoint @@ -877,22 +909,25 @@ def process_request( _cid = request["client_id"] _context = self.server_get("endpoint_context") cinfo = _context.cdb[_cid] - logger.debug("client {}: {}".format(_cid, cinfo)) + # logger.debug("client {}: {}".format(_cid, cinfo)) # this apply the default optionally deny_unknown_scopes policy - if cinfo: - check_unknown_scopes_policy(request, cinfo, _context) + check_unknown_scopes_policy(request, _cid, _context) if http_info is None: http_info = {} _cookies = http_info.get("cookie") if _cookies: - _cookies = _context.cookie_handler.parse_cookie("oidcop", _cookies) + logger.debug("parse_cookie@process_request") + _session_cookie_name = _context.cookie_handler.name["session"] + _my_cookies = _context.cookie_handler.parse_cookie(_session_cookie_name, _cookies) + else: + _my_cookies = {} kwargs = self.do_request_user(request_info=request, **kwargs) - info = self.setup_auth(request, request["redirect_uri"], cinfo, _cookies, **kwargs) + info = self.setup_auth(request, request["redirect_uri"], cinfo, _my_cookies, **kwargs) if "error" in info: return info @@ -901,7 +936,7 @@ def process_request( if not _function: logger.debug("- authenticated -") logger.debug("AREQ keys: %s" % request.keys()) - return self.authz_part2(request=request, cookie=_cookies, **info) + return self.authz_part2(request=request, cookie=_my_cookies, **info) try: # Run the authentication function @@ -934,11 +969,19 @@ def __call__(self, client_id, endpoint_context, alg, alg_type): def re_authenticate(request, authn) -> bool: """ - This is where you can demand reauthentication even though the authentication in use + This is where you can demand re-authentication even though the authentication in use is still valid. :param request: :param authn: :return: """ + logger.debug("Re-authenticate ??: {}".format(request)) + + _prompt = request.get("prompt", []) + logger.debug(f"Prompt={_prompt}") + if "login" in _prompt: + logger.debug("Reauthenticate due to prompt=login") + return True + return False diff --git a/src/oidcop/oauth2/token.py b/src/oidcop/oauth2/token.py index e84ea887..768c1734 100755 --- a/src/oidcop/oauth2/token.py +++ b/src/oidcop/oauth2/token.py @@ -4,7 +4,6 @@ from cryptojwt.jwe.exception import JWEException from cryptojwt.jwt import utc_time_sans_frac - from oidcmsg.message import Message from oidcmsg.oauth2 import AccessTokenResponse from oidcmsg.oauth2 import ResponseMessage @@ -13,6 +12,7 @@ from oidcmsg.time_util import time_sans_frac from oidcop import sanitize +from oidcop.constant import DEFAULT_TOKEN_LIFETIME from oidcop.endpoint import Endpoint from oidcop.exception import ProcessError from oidcop.session.grant import AuthorizationCode @@ -62,7 +62,7 @@ def _mint_token( if usage_rules: _exp_in = usage_rules.get("expires_in") else: - _exp_in = 0 + _exp_in = DEFAULT_TOKEN_LIFETIME token_args = token_args or {} for meth in _context.token_args_methods: @@ -106,9 +106,8 @@ def process_request(self, req: Union[Message, dict], **kwargs): :return: """ _context = self.endpoint.server_get("endpoint_context") - _mngr = _context.session_manager - _log_debug = logger.debug + logger.debug("Access Token") if req["grant_type"] != "authorization_code": return self.error_cls(error="invalid_request", error_description="Unknown grant_type") @@ -119,6 +118,16 @@ def process_request(self, req: Union[Message, dict], **kwargs): return self.error_cls(error="invalid_request", error_description="Missing code") _session_info = _mngr.get_session_info_by_token(_access_code, grant=True) + client_id = _session_info["client_id"] + if client_id != req["client_id"]: + logger.debug("{} owner of token".format(client_id)) + logger.warning("Client using token it was not given") + return self.error_cls(error="invalid_grant", error_description="Wrong client") + + if "grant_types_supported" in _context.cdb[client_id]: + grant_types_supported = _context.cdb[client_id].get("grant_types_supported") + else: + grant_types_supported = _context.provider_info["grant_types_supported"] grant = _session_info["grant"] _based_on = grant.get_token(_access_code) @@ -134,7 +143,7 @@ def process_request(self, req: Union[Message, dict], **kwargs): error="invalid_request", error_description="redirect_uri mismatch" ) - _log_debug("All checks OK") + logger.debug("All checks OK") issue_refresh = kwargs.get("issue_refresh", False) _response = { @@ -158,7 +167,11 @@ def process_request(self, req: Union[Message, dict], **kwargs): if token.expires_at: _response["expires_in"] = token.expires_at - utc_time_sans_frac() - if issue_refresh and "refresh_token" in _supports_minting: + if ( + issue_refresh + and "refresh_token" in _supports_minting + and "refresh_token" in grant_types_supported + ): try: refresh_token = self._mint_token( token_class="refresh_token", @@ -219,12 +232,20 @@ class RefreshTokenHelper(TokenEndpointHelper): def process_request(self, req: Union[Message, dict], **kwargs): _context = self.endpoint.server_get("endpoint_context") _mngr = _context.session_manager + logger.debug("Refresh Token") if req["grant_type"] != "refresh_token": return self.error_cls(error="invalid_request", error_description="Wrong grant_type") token_value = req["refresh_token"] _session_info = _mngr.get_session_info_by_token(token_value, grant=True) + logger.debug("Session info: {}".format(_session_info)) + + if _session_info["client_id"] != req["client_id"]: + logger.debug("{} owner of token".format(_session_info["client_id"])) + logger.warning("Client using token it was not given") + return self.error_cls(error="invalid_grant", error_description="Wrong client") + _grant = _session_info["grant"] token_type = "Bearer" @@ -275,6 +296,17 @@ def process_request(self, req: Union[Message, dict], **kwargs): token.register_usage() + if ("client_id" in req + and req["client_id"] in _context.cdb + and "revoke_refresh_on_issue" in _context.cdb[req["client_id"]] + ): + revoke_refresh = _context.cdb[req["client_id"]].get("revoke_refresh_on_issue") + else: + revoke_refresh = self.endpoint.revoke_refresh_on_issue + + if revoke_refresh: + token.revoke() + return _resp def post_parse_request( @@ -353,6 +385,7 @@ def __init__(self, server_get, new_refresh_token=False, **kwargs): self.allow_refresh = False self.new_refresh_token = new_refresh_token self.configure_grant_types(kwargs.get("grant_types_supported")) + self.revoke_refresh_on_issue = kwargs.get("revoke_refresh_on_issue", False) def configure_grant_types(self, grant_types_supported): if grant_types_supported is None: @@ -390,13 +423,20 @@ def configure_grant_types(self, grant_types_supported): def _post_parse_request( self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs ): - _helper = self.helper.get(request["grant_type"]) + grant_type = request["grant_type"] + _helper = self.helper.get(grant_type) + client = kwargs["endpoint_context"].cdb[client_id] + if "grant_types_supported" in client and grant_type not in client["grant_types_supported"]: + return self.error_cls( + error="invalid_request", + error_description=f"Unsupported grant_type: {grant_type}", + ) if _helper: return _helper.post_parse_request(request, client_id, **kwargs) else: return self.error_cls( error="invalid_request", - error_description=f"Unsupported grant_type: {request['grant_type']}", + error_description=f"Unsupported grant_type: {grant_type}", ) def process_request(self, request: Optional[Union[Message, dict]] = None, **kwargs): diff --git a/src/oidcop/oidc/add_on/custom_scopes.py b/src/oidcop/oidc/add_on/custom_scopes.py index 1366548f..e3981e18 100644 --- a/src/oidcop/oidc/add_on/custom_scopes.py +++ b/src/oidcop/oidc/add_on/custom_scopes.py @@ -10,17 +10,22 @@ def add_custom_scopes(endpoint, **kwargs): :param endpoint: A dictionary with endpoint instances as values """ # Just need an endpoint, anyone will do + LOGGER.warning( + "The custom_scopes add on is deprecated. The `scopes_to_claims` config " + "option should be used instead." + ) _endpoint = list(endpoint.values())[0] _scopes2claims = SCOPE2CLAIMS.copy() _scopes2claims.update(kwargs) _context = _endpoint.server_get("endpoint_context") - _context.scope2claims = _scopes2claims + _context.scopes_handler.scopes_to_claims = _scopes2claims pi = _context.provider_info _scopes = set(pi.get("scopes_supported", [])) _scopes.update(set(kwargs.keys())) pi["scopes_supported"] = list(_scopes) + _context.scopes_handler.allowed_scopes = pi["scopes_supported"] _claims = set(pi.get("claims_supported", [])) for vals in kwargs.values(): diff --git a/src/oidcop/oidc/registration.py b/src/oidcop/oidc/registration.py index 71aa7374..cc67dcd1 100755 --- a/src/oidcop/oidc/registration.py +++ b/src/oidcop/oidc/registration.py @@ -473,4 +473,4 @@ def process_request(self, request=None, new_id=True, set_secret=True, **kwargs): name=_context.cookie_handler.name["register"], client_id=reg_resp["client_id"], ) - return {"response_args": reg_resp, "cookie": _cookie} + return {"response_args": reg_resp, "cookie": _cookie, "response_code": 201} diff --git a/src/oidcop/oidc/session.py b/src/oidcop/oidc/session.py index b3db0e42..4e35141e 100644 --- a/src/oidcop/oidc/session.py +++ b/src/oidcop/oidc/session.py @@ -240,6 +240,7 @@ def process_request( _session_info = None if _cookies: + logger.debug("parse_cookie@session") _cookie_name = _context.cookie_handler.name["session"] try: _cookie_infos = _context.cookie_handler.parse_cookie( diff --git a/src/oidcop/oidc/token.py b/src/oidcop/oidc/token.py index a88f45c8..07bb41a5 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -32,7 +32,7 @@ def process_request(self, req: Union[Message, dict], **kwargs): _context = self.endpoint.server_get("endpoint_context") _mngr = _context.session_manager - _log_debug = logger.debug + logger.debug("OIDC Access Token") if req["grant_type"] != "authorization_code": return self.error_cls(error="invalid_request", error_description="Unknown grant_type") @@ -43,6 +43,18 @@ def process_request(self, req: Union[Message, dict], **kwargs): return self.error_cls(error="invalid_request", error_description="Missing code") _session_info = _mngr.get_session_info_by_token(_access_code, grant=True) + logger.debug(f"Session info: {_session_info}") + + client_id = _session_info["client_id"] + if client_id != req["client_id"]: + logger.debug("{} owner of token".format(client_id)) + logger.warning("{} using token it was not given".format(req["client_id"])) + return self.error_cls(error="invalid_grant", error_description="Wrong client") + + if "grant_types_supported" in _context.cdb[client_id]: + grant_types_supported = _context.cdb[client_id].get("grant_types_supported") + else: + grant_types_supported = _context.provider_info["grant_types_supported"] grant = _session_info["grant"] token_type = "Bearer" @@ -72,14 +84,12 @@ def process_request(self, req: Union[Message, dict], **kwargs): error="invalid_request", error_description="redirect_uri mismatch" ) - _log_debug("All checks OK") + logger.debug("All checks OK") - issue_refresh = False - if "issue_refresh" in kwargs: - issue_refresh = kwargs["issue_refresh"] - else: - if "offline_access" in grant.scope: - issue_refresh = True + issue_refresh = kwargs.get("issue_refresh", None) + # The existence of offline_access scope overwrites issue_refresh + if issue_refresh is None and "offline_access" in grant.scope: + issue_refresh = True _response = { "token_type": token_type, @@ -103,7 +113,11 @@ def process_request(self, req: Union[Message, dict], **kwargs): if token.expires_at: _response["expires_in"] = token.expires_at - utc_time_sans_frac() - if issue_refresh and "refresh_token" in _supports_minting: + if ( + issue_refresh + and "refresh_token" in _supports_minting + and "refresh_token" in grant_types_supported + ): try: refresh_token = self._mint_token( token_class="refresh_token", @@ -145,7 +159,7 @@ def process_request(self, req: Union[Message, dict], **kwargs): return _response def post_parse_request( - self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs + self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs ): """ This is where clients come to get their access tokens @@ -167,8 +181,13 @@ def post_parse_request( if not isinstance(code, AuthorizationCode): return self.error_cls(error="invalid_request", error_description="Wrong token type") + if code.used: # Has been used already + # invalidate all tokens that has been minted using this code + grant.revoke_token(based_on=request["code"], recursive=True) + return self.error_cls(error="invalid_grant", error_description="Code inactive") + if code.is_active() is False: - return self.error_cls(error="invalid_request", error_description="Code inactive") + return self.error_cls(error="invalid_grant", error_description="Code inactive") _auth_req = grant.authorization_request @@ -190,6 +209,11 @@ 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) + 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"])) + return self.error_cls(error="invalid_grant", error_description="Wrong client") + _grant = _session_info["grant"] token_type = "Bearer" @@ -225,12 +249,12 @@ def process_request(self, req: Union[Message, dict], **kwargs): _resp["expires_in"] = access_token.expires_at - utc_time_sans_frac() _mints = token.usage_rules.get("supports_minting") - issue_refresh = False - if "issue_refresh" in kwargs: - issue_refresh = kwargs["issue_refresh"] - else: - if "offline_access" in scope: - issue_refresh = True + + issue_refresh = kwargs.get("issue_refresh", None) + # The existence of offline_access scope overwrites issue_refresh + if issue_refresh is None and "offline_access" in scope: + issue_refresh = True + if "refresh_token" in _mints and issue_refresh: refresh_token = self._mint_token( token_class="refresh_token", @@ -264,10 +288,21 @@ def process_request(self, req: Union[Message, dict], **kwargs): token.register_usage() + if ("client_id" in req + and req["client_id"] in _context.cdb + and "revoke_refresh_on_issue" in _context.cdb[req["client_id"]] + ): + revoke_refresh = _context.cdb[req["client_id"]].get("revoke_refresh_on_issue") + else: + revoke_refresh = revoke_refresh = self.endpoint.revoke_refresh_on_issue + + if revoke_refresh: + token.revoke() + return _resp def post_parse_request( - self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs + self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs ): """ This is where clients come to refresh their access tokens diff --git a/src/oidcop/oidc/userinfo.py b/src/oidcop/oidc/userinfo.py index 1b514c01..1abbbaa0 100755 --- a/src/oidcop/oidc/userinfo.py +++ b/src/oidcop/oidc/userinfo.py @@ -32,7 +32,7 @@ class UserInfo(Endpoint): "userinfo_signing_alg_values_supported": None, "userinfo_encryption_alg_values_supported": None, "userinfo_encryption_enc_values_supported": None, - "client_authn_method": ["bearer_header"], + "client_authn_method": ["bearer_header", "bearer_body"], } def __init__(self, server_get: Callable, add_claims_by_scope: Optional[bool] = True, **kwargs): @@ -107,7 +107,13 @@ def do_response( def process_request(self, request=None, **kwargs): _mngr = self.server_get("endpoint_context").session_manager - _session_info = _mngr.get_session_info_by_token(request["access_token"], grant=True) + try: + _session_info = _mngr.get_session_info_by_token( + request["access_token"], grant=True + ) + except (KeyError, ValueError): + return self.error_cls(error="invalid_token", error_description="Invalid Token") + _grant = _session_info["grant"] token = _grant.get_token(request["access_token"]) # should be an access token diff --git a/src/oidcop/scopes.py b/src/oidcop/scopes.py index ec772040..8a15bf97 100644 --- a/src/oidcop/scopes.py +++ b/src/oidcop/scopes.py @@ -25,26 +25,18 @@ } -def available_scopes(endpoint_context): - _supported = endpoint_context.provider_info.get("scopes_supported") - if _supported: - return [s for s in endpoint_context.scope2claims.keys() if s in _supported] - else: - return [s for s in endpoint_context.scope2claims.keys()] - - def convert_scopes2claims(scopes, allowed_claims=None, scope2claim_map=None): scope2claim_map = scope2claim_map or SCOPE2CLAIMS res = {} if allowed_claims is None: for scope in scopes: - claims = {name: None for name in scope2claim_map[scope]} + claims = {name: None for name in scope2claim_map.get(scope, [])} res.update(claims) else: for scope in scopes: try: - claims = {name: None for name in scope2claim_map[scope] if name in allowed_claims} + claims = {name: None for name in scope2claim_map.get(scope, []) if name in allowed_claims} res.update(claims) except KeyError: continue @@ -53,26 +45,55 @@ def convert_scopes2claims(scopes, allowed_claims=None, scope2claim_map=None): class Scopes: - def __init__(self): - pass + def __init__(self, server_get, allowed_scopes=None, scopes_to_claims=None): + self.server_get = server_get + if not scopes_to_claims: + scopes_to_claims = dict(SCOPE2CLAIMS) + self._scopes_to_claims = scopes_to_claims + if not allowed_scopes: + allowed_scopes = list(scopes_to_claims.keys()) + self.allowed_scopes = allowed_scopes - def allowed_scopes(self, client_id, endpoint_context): + def get_allowed_scopes(self, client_id=None): """ Returns the set of scopes that a specific client can use. :param client_id: The client identifier - :param endpoint_context: A EndpointContext instance :returns: List of scope names. Can be empty. """ - _cli = endpoint_context.cdb.get(client_id) - if _cli is not None: - _scopes = _cli.get("allowed_scopes") - if _scopes: - return _scopes - else: - return available_scopes(endpoint_context) - return [] - - def filter_scopes(self, client_id, endpoint_context, scopes): - allowed_scopes = self.allowed_scopes(client_id, endpoint_context) + allowed_scopes = self.allowed_scopes + if client_id: + client = self.server_get("endpoint_context").cdb.get(client_id) + if client is not None: + if "allowed_scopes" in client: + allowed_scopes = client.get("allowed_scopes") + elif "scopes_to_claims" in client: + allowed_scopes = list(client.get("scopes_to_claims").keys()) + + return allowed_scopes + + def get_scopes_mapping(self, client_id=None): + """ + Returns the mapping of scopes to claims fora specific client. + + :param client_id: The client identifier + :returns: Dict of scopes to claims. Can be empty. + """ + scopes_to_claims = self._scopes_to_claims + if client_id: + client = self.server_get("endpoint_context").cdb.get(client_id) + if client is not None: + scopes_to_claims = client.get("scopes_to_claims", scopes_to_claims) + return scopes_to_claims + + def filter_scopes(self, scopes, client_id=None): + allowed_scopes = self.get_allowed_scopes(client_id) return [s for s in scopes if s in allowed_scopes] + + def scopes_to_claims(self, scopes, scopes_to_claims=None, client_id=None): + if not scopes_to_claims: + scopes_to_claims = self.get_scopes_mapping(client_id) + + scopes = self.filter_scopes(scopes, client_id) + + return convert_scopes2claims(scopes, scope2claim_map=scopes_to_claims) diff --git a/src/oidcop/server.py b/src/oidcop/server.py index 99349fbe..c7d698ea 100644 --- a/src/oidcop/server.py +++ b/src/oidcop/server.py @@ -67,7 +67,12 @@ def __init__( ImpExp.__init__(self) self.conf = conf self.endpoint_context = EndpointContext( - conf=conf, keyjar=keyjar, cwd=cwd, cookie_handler=cookie_handler, httpc=httpc, + conf=conf, + server_get=self.server_get, + keyjar=keyjar, + cwd=cwd, + cookie_handler=cookie_handler, + httpc=httpc, ) self.endpoint_context.authz = self.do_authz() diff --git a/src/oidcop/session/claims.py b/src/oidcop/session/claims.py index 5ac1991c..2251b4bf 100755 --- a/src/oidcop/session/claims.py +++ b/src/oidcop/session/claims.py @@ -4,8 +4,8 @@ from oidcmsg.oidc import OpenIDSchema -from oidcop.exception import ServiceError from oidcop.exception import ImproperlyConfigured +from oidcop.exception import ServiceError from oidcop.scopes import convert_scopes2claims logger = logging.getLogger(__name__) @@ -31,12 +31,13 @@ class ClaimsInterface: def __init__(self, server_get): self.server_get = server_get - def authorization_request_claims(self, - session_id: str, - claims_release_point: Optional[str] = "") -> dict: - _grant = self.server_get("endpoint_context").session_manager.get_grant(session_id) - if _grant.authorization_request and "claims" in _grant.authorization_request: - return _grant.authorization_request["claims"].get(claims_release_point, {}) + def authorization_request_claims( + self, + authorization_request: dict, + claims_release_point: Optional[str] = "", + ) -> dict: + if authorization_request and "claims" in authorization_request: + return authorization_request["claims"].get(claims_release_point, {}) return {} @@ -70,16 +71,13 @@ def _get_module(self, usage, endpoint_context): return module - def get_claims(self, session_id: str, scopes: str, claims_release_point: str) -> dict: - """ - - :param session_id: Session identifier - :param scopes: Scopes - :param claims_release_point: Where to release the claims. One of - "userinfo"/"id_token"/"introspection"/"access_token" - :return: Claims specification as a dictionary. - """ - + def get_claims_from_request( + self, + auth_req: dict, + claims_release_point: str, + scopes: str = None, + client_id: str = None, + ): _context = self.server_get("endpoint_context") # which endpoint module configuration to get the base claims from module = self._get_module(claims_release_point, _context) @@ -89,7 +87,8 @@ def get_claims(self, session_id: str, scopes: str, claims_release_point: str) -> else: return {} - user_id, client_id, grant_id = _context.session_manager.decrypt_session_id(session_id) + if not client_id: + client_id = auth_req.get("client_id") # Can there be per client specification of which claims to use. if module.kwargs.get("enable_claims_per_client"): @@ -112,30 +111,76 @@ def get_claims(self, session_id: str, scopes: str, claims_release_point: str) -> add_claims_by_scope = module.kwargs.get("add_claims_by_scope") if add_claims_by_scope: + if scopes is None: + scopes = auth_req.get("scope") if scopes: - _scopes = _context.scopes_handler.filter_scopes(client_id, _context, scopes) - - _claims = convert_scopes2claims(_scopes, scope2claim_map=_context.scope2claims) + _claims = _context.scopes_handler.scopes_to_claims(scopes, client_id=client_id) claims.update(_claims) # Bring in claims specification from the authorization request # This only goes for ID Token and user info - request_claims = self.authorization_request_claims(session_id=session_id, - claims_release_point=claims_release_point) + request_claims = self.authorization_request_claims( + authorization_request=auth_req, + claims_release_point=claims_release_point + ) # This will add claims that has not be added before and - # set filters on those claims that also appears in one of the sources above + # set filters on those claims that also appears in one of the sources + # above if request_claims: claims.update(request_claims) return claims - def get_claims_all_usage(self, session_id: str, scopes: str) -> dict: + def get_claims(self, session_id: str, scopes: str, claims_release_point: str) -> dict: + """ + + :param session_id: Session identifier + :param scopes: Scopes + :param claims_release_point: Where to release the claims. One of + "userinfo"/"id_token"/"introspection"/"access_token" + :return: Claims specification as a dictionary. + """ + _context = self.server_get("endpoint_context") + session_info = _context.session_manager.get_session_info( + session_id, grant=True + ) + client_id = session_info["client_id"] + grant = session_info["grant"] + + if grant.authorization_request: + auth_req = grant.authorization_request + else: + auth_req = {} + claims = self.get_claims_from_request( + auth_req=auth_req, + claims_release_point=claims_release_point, + scopes=scopes, + client_id=client_id, + ) + + return claims + + def get_claims_all_usage_from_request( + self, auth_req: dict, scopes: str = None, client_id: str = None + ) -> dict: _claims = {} for usage in self.claims_release_points: - _claims[usage] = self.get_claims(session_id, scopes, usage) + _claims[usage] = self.get_claims_from_request( + auth_req, usage, scopes=scopes, client_id=client_id + ) return _claims + def get_claims_all_usage(self, session_id: str, scopes: str) -> dict: + grant = self.server_get( + "endpoint_context" + ).session_manager.get_grant(session_id) + if grant.authorization_request: + auth_req = grant.authorization_request + else: + auth_req = {} + return self.get_claims_all_usage_from_request(auth_req, scopes) + def get_user_claims(self, user_id: str, claims_restriction: dict) -> dict: """ diff --git a/src/oidcop/token/__init__.py b/src/oidcop/token/__init__.py index a9bcd791..9134bf55 100755 --- a/src/oidcop/token/__init__.py +++ b/src/oidcop/token/__init__.py @@ -54,11 +54,10 @@ def __call__(self, session_id: Optional[str] = "", ttype: Optional[str] = "", ** def info(self, token): """ - Return type of Token (A=Access code, T=Token, R=Refresh token) and - the session id. + Return dictionary with token information. :param token: A token - :return: tuple of token type and session id + :return: Dictionary with information about the token """ raise NotImplementedError() diff --git a/src/oidcop/token/jwt_token.py b/src/oidcop/token/jwt_token.py index d19024c9..7d4f12b5 100644 --- a/src/oidcop/token/jwt_token.py +++ b/src/oidcop/token/jwt_token.py @@ -7,12 +7,10 @@ from oidcop.exception import ToOld from oidcop.token import Crypt from oidcop.token.exception import WrongTokenClass - from . import Token from . import is_expired from .exception import UnknownToken - -# TYPE_MAP = {"A": "code", "T": "access_token", "R": "refresh_token"} +from ..constant import DEFAULT_TOKEN_LIFETIME class JWTToken(Token): @@ -23,7 +21,7 @@ def __init__( issuer: str = None, aud: Optional[list] = None, alg: str = "ES256", - lifetime: int = 300, + lifetime: int = DEFAULT_TOKEN_LIFETIME, server_get: Callable = None, token_type: str = "Bearer", password: str = "", diff --git a/src/oidcop/user_authn/user.py b/src/oidcop/user_authn/user.py index b8baba08..5b5bdded 100755 --- a/src/oidcop/user_authn/user.py +++ b/src/oidcop/user_authn/user.py @@ -5,12 +5,13 @@ import logging import sys import time -import warnings from typing import List from urllib.parse import unquote +import warnings from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptojwt.jwt import JWT +from oidcmsg.time_util import utc_time_sans_frac from oidcop.exception import FailedAuthentication from oidcop.exception import ImproperlyConfigured @@ -65,6 +66,15 @@ def authenticated_as(self, client_id, cookie=None, **kwargs): return None, 0 else: _info = self.cookie_info(cookie, client_id) + logger.debug('Cookie info: {}'.format(_info)) + if _info: + if 'max_age' in kwargs and kwargs["max_age"] != 0: + _max_age = kwargs["max_age"] + _now = utc_time_sans_frac() + if _now > _info["timestamp"] + _max_age: + logger.debug( + "Too old by {} seconds".format(_now - (_info["timestamp"] + _max_age))) + return None, 0 return _info, time.time() def verify(self, *args, **kwargs): @@ -93,22 +103,30 @@ def done(self, areq): def cookie_info(self, cookie: List[dict], client_id: str) -> dict: _context = self.server_get("endpoint_context") try: + logger.debug("parse_cookie@UserAuthnMethod") vals = _context.cookie_handler.parse_cookie( cookies=cookie, name=_context.cookie_handler.name["session"] ) except (InvalidCookieSign, AssertionError, AttributeError) as err: logger.warning(err) - vals = None + vals = [] + + logger.debug("Value cookies: {}".format(vals)) if vals is None: pass else: for val in vals: _info = json.loads(val["value"]) - _, cid, _ = _context.session_manager.decrypt_session_id(_info["sid"]) - if cid != client_id: + _info["timestamp"] = int(val["timestamp"]) + session_id = _context.session_manager.decrypt_session_id(_info["sid"]) + logger.debug("session id: {}".format(session_id)) + # _, cid, _ = _context.session_manager.decrypt_session_id(_info["sid"]) + if session_id[1] != client_id: continue else: + _info["uid"] = session_id[0] + _info["grant_id"] = session_id[2] return _info return {} @@ -130,13 +148,13 @@ class UserPassJinja2(UserAuthnMethod): url_endpoint = "/verify/user_pass_jinja" def __init__( - self, - db, - template_handler, - template="user_pass.jinja2", - server_get=None, - verify_endpoint="", - **kwargs, + self, + db, + template_handler, + template="user_pass.jinja2", + server_get=None, + verify_endpoint="", + **kwargs, ): super(UserPassJinja2, self).__init__(server_get=server_get) diff --git a/tests/test_01_claims.py b/tests/test_01_claims.py index 4b027c45..ca60643c 100644 --- a/tests/test_01_claims.py +++ b/tests/test_01_claims.py @@ -141,33 +141,24 @@ def _create_session(self, auth_req, sub_type="public", sector_identifier=""): ) def test_authorization_request_id_token_claims(self): - session_id = self._create_session(AREQ) - - claims = self.claims_interface.authorization_request_claims(session_id, "id_token") + claims = self.claims_interface.authorization_request_claims(AREQ, "id_token") assert claims == {} def test_authorization_request_id_token_claims_2(self): - session_id = self._create_session(AREQ_2) - claims = self.claims_interface.authorization_request_claims(session_id, "id_token") + claims = self.claims_interface.authorization_request_claims(AREQ_2, "id_token") assert claims assert set(claims.keys()) == {"nickname"} def test_authorization_request_userinfo_claims(self): - session_id = self._create_session(AREQ) - - claims = self.claims_interface.authorization_request_claims(session_id, "userinfo") + claims = self.claims_interface.authorization_request_claims(AREQ, "userinfo") assert claims == {} def test_authorization_request_userinfo_claims_2(self): - session_id = self._create_session(AREQ_2) - - claims = self.claims_interface.authorization_request_claims(session_id, "userinfo") + claims = self.claims_interface.authorization_request_claims(AREQ_2, "userinfo") assert claims == {} def test_authorization_request_userinfo_claims_3(self): - session_id = self._create_session(AREQ_3) - - claims = self.claims_interface.authorization_request_claims(session_id, "userinfo") + claims = self.claims_interface.authorization_request_claims(AREQ_3, "userinfo") assert set(claims.keys()) == {"name", "email", "email_verified"} @pytest.mark.parametrize("usage", ["id_token", "userinfo", "introspection", "token"]) diff --git a/tests/test_06_session_manager.py b/tests/test_06_session_manager.py index 0fae6fa1..eaa189e8 100644 --- a/tests/test_06_session_manager.py +++ b/tests/test_06_session_manager.py @@ -200,11 +200,12 @@ def _mint_token(self, token_class, grant, session_id, based_on=None): ) def test_grant(self): - grant = Grant() + sid = self._create_session(AUTH_REQ) + grant = self.session_manager.get_grant(sid) assert grant.issued_token == [] assert grant.is_active() is True - code = self._mint_token("authorization_code", grant, self.dummy_session_id) + code = self._mint_token("authorization_code", grant, sid) assert isinstance(code, AuthorizationCode) assert code.is_active() assert len(grant.issued_token) == 1 diff --git a/tests/test_07_userinfo.py b/tests/test_07_userinfo.py index 486614a2..9f1142e6 100644 --- a/tests/test_07_userinfo.py +++ b/tests/test_07_userinfo.py @@ -1,11 +1,11 @@ import json import os -from oidcop.configure import OPConfiguration import pytest from oidcmsg.oidc import OpenIDRequest from oidcop.authn_event import create_authn_event +from oidcop.configure import OPConfiguration from oidcop.oidc import userinfo from oidcop.oidc.authorization import Authorization from oidcop.oidc.provider_config import ProviderConfiguration @@ -388,83 +388,89 @@ def test_collect_user_info_enable_claims_per_client(self): class TestCollectUserInfoCustomScopes: - @pytest.fixture(autouse=True) - def create_endpoint_context(self): - self.server = Server( - { - "userinfo": {"class": UserInfo, "kwargs": {"db": USERINFO_DB}}, - "password": "we didn't start the fire", - "issuer": "https://example.com/op", - "claims_interface": {"class": "oidcop.session.claims.OAuth2ClaimsInterface", - "kwargs": {}}, - "endpoint": { - "provider_config": { - "path": "{}/.well-known/openid-configuration", - "class": ProviderConfiguration, - "kwargs": {}, - }, - "registration": { - "path": "{}/registration", - "class": Registration, - "kwargs": {}, - }, - "authorization": { - "path": "{}/authorization", - "class": Authorization, - "kwargs": { - "response_types_supported": [ - " ".join(x) for x in RESPONSE_TYPES_SUPPORTED - ], - "response_modes_supported": ["query", "fragment", "form_post",], - "claims_parameter_supported": True, - "request_parameter_supported": True, - "request_uri_parameter_supported": True, - }, - }, - "userinfo": { - "path": "userinfo", - "class": userinfo.UserInfo, - "kwargs": { - "claim_types_supported": ["normal", "aggregated", "distributed",], - "client_authn_method": ["bearer_header"], - "base_claims": {"eduperson_scoped_affiliation": None, "email": None,}, - "add_claims_by_scope": True, - "enable_claims_per_client": True, - }, - }, + @pytest.fixture + def conf(self): + return { + "userinfo": {"class": UserInfo, "kwargs": {"db": USERINFO_DB}}, + "password": "we didn't start the fire", + "issuer": "https://example.com/op", + "claims_interface": {"class": "oidcop.session.claims.ClaimsInterface", "kwargs": {}}, + "endpoint": { + "provider_config": { + "path": "{}/.well-known/openid-configuration", + "class": ProviderConfiguration, + "kwargs": {}, }, - "add_on": { - "custom_scopes": { - "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes", - "kwargs": { - "research_and_scholarship": [ - "name", - "given_name", - "family_name", - "email", - "email_verified", - "sub", - "iss", - "eduperson_scoped_affiliation", - ] - }, - } + "registration": { + "path": "{}/registration", + "class": Registration, + "kwargs": {}, }, - "keys": { - "public_path": "jwks.json", - "key_defs": KEYDEFS, - "uri_path": "static/jwks.json", + "authorization": { + "path": "{}/authorization", + "class": Authorization, + "kwargs": { + "response_types_supported": [" ".join(x) for x in RESPONSE_TYPES_SUPPORTED], + "response_modes_supported": [ + "query", + "fragment", + "form_post", + ], + "claims_parameter_supported": True, + "request_parameter_supported": True, + "request_uri_parameter_supported": True, + }, }, - "authentication": { - "anon": { - "acr": INTERNETPROTOCOLPASSWORD, - "class": "oidcop.user_authn.user.NoAuthn", - "kwargs": {"user": "diana"}, - } + "userinfo": { + "path": "userinfo", + "class": userinfo.UserInfo, + "kwargs": { + "claim_types_supported": [ + "normal", + "aggregated", + "distributed", + ], + "client_authn_method": ["bearer_header"], + "base_claims": { + "eduperson_scoped_affiliation": None, + "email": None, + }, + "add_claims_by_scope": True, + "enable_claims_per_client": True, + }, }, - "template_dir": "template", - } - ) + }, + "scopes_to_claims": { + "openid": ["sub"], + "research_and_scholarship": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "iss", + "eduperson_scoped_affiliation", + ], + }, + "keys": { + "public_path": "jwks.json", + "key_defs": KEYDEFS, + "uri_path": "static/jwks.json", + }, + "authentication": { + "anon": { + "acr": INTERNETPROTOCOLPASSWORD, + "class": "oidcop.user_authn.user.NoAuthn", + "kwargs": {"user": "diana"}, + } + }, + "template_dir": "template", + } + + @pytest.fixture(autouse=True) + def create_endpoint_context(self, conf): + self.server = Server(conf) self.endpoint_context = self.server.endpoint_context self.endpoint_context.cdb["client1"] = {} self.session_manager = self.endpoint_context.session_manager @@ -513,3 +519,66 @@ def test_collect_user_info_custom_scope(self): "given_name": "Diana", "name": "Diana Krall", } + + def test_collect_user_info_scope_mapping_per_client(self, conf): + conf["scopes_to_claims"] = SCOPE2CLAIMS + server = Server(conf) + endpoint_context = server.endpoint_context + self.session_manager = endpoint_context.session_manager + claims_interface = endpoint_context.claims_interface + endpoint_context.cdb["client1"] = { + "scopes_to_claims": { + "openid": ["sub"], + "research_and_scholarship": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "iss", + "eduperson_scoped_affiliation", + ], + } + } + + _req = OIDR.copy() + _req["scope"] = "openid research_and_scholarship" + del _req["claims"] + + session_id = self._create_session(_req) + + _restriction = claims_interface.get_claims( + session_id=session_id, scopes=_req["scope"], claims_release_point="userinfo" + ) + + res = claims_interface.get_user_claims("diana", _restriction) + + assert res == { + "eduperson_scoped_affiliation": ["staff@example.org"], + "email": "diana@example.org", + "email_verified": False, + "family_name": "Krall", + "given_name": "Diana", + "name": "Diana Krall", + } + + def test_collect_user_info_allowed_scopes_per_client(self): + self.endpoint_context.cdb["client1"] = {"allowed_scopes": {"openid"}} + + _req = OIDR.copy() + _req["scope"] = "openid research_and_scholarship" + del _req["claims"] + + session_id = self._create_session(_req) + + _restriction = self.claims_interface.get_claims( + session_id=session_id, scopes=_req["scope"], claims_release_point="userinfo" + ) + + res = self.claims_interface.get_user_claims("diana", _restriction) + + assert res == { + "eduperson_scoped_affiliation": ["staff@example.org"], + "email": "diana@example.org", + } diff --git a/tests/test_12_user_authn.py b/tests/test_12_user_authn.py index 14585267..3cf89fcc 100644 --- a/tests/test_12_user_authn.py +++ b/tests/test_12_user_authn.py @@ -100,7 +100,8 @@ def test_authenticated_as_with_cookie(self): ) _info, _time_stamp = method.authenticated_as("client 12345", [_cookie]) - assert set(_info.keys()) == {"sub", "sid", "state", "client_id"} + assert set(_info.keys()) == {'sub', 'uid', 'state', 'grant_id', 'timestamp', 'sid', + 'client_id'} assert _info["sub"] == "diana" def test_userpassjinja2(self): diff --git a/tests/test_22_oidc_provider_config_endpoint.py b/tests/test_22_oidc_provider_config_endpoint.py index df950023..a124cfa9 100755 --- a/tests/test_22_oidc_provider_config_endpoint.py +++ b/tests/test_22_oidc_provider_config_endpoint.py @@ -1,9 +1,9 @@ import json import os -from oidcop.configure import OPConfiguration import pytest +from oidcop.configure import OPConfiguration from oidcop.oidc.provider_config import ProviderConfiguration from oidcop.oidc.token import Token from oidcop.server import Server @@ -50,9 +50,9 @@ class TestEndpoint(object): - @pytest.fixture(autouse=True) - def create_endpoint(self): - conf = { + @pytest.fixture + def conf(self): + return { "issuer": "https://example.com/", "password": "mycket hemligt", "verify_ssl": False, @@ -68,6 +68,9 @@ def create_endpoint(self): }, "template_dir": "template", } + + @pytest.fixture(autouse=True) + def create_endpoint(self, conf): server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) self.endpoint_context = server.endpoint_context @@ -104,3 +107,32 @@ def test_do_response(self): "birthdate", } assert ("Content-type", "application/json; charset=utf-8") in msg["http_headers"] + + def test_scopes_supported(self, conf): + scopes_supported = ["openid", "random", "profile"] + conf["capabilities"]["scopes_supported"] = scopes_supported + + server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) + endpoint = server.server_get("endpoint", "provider_config") + args = endpoint.process_request() + msg = endpoint.do_response(args["response_args"]) + assert isinstance(msg, dict) + _msg = json.loads(msg["response"]) + assert set(_msg["scopes_supported"]) == set(scopes_supported) + assert set(_msg["claims_supported"]) == { + "zoneinfo", + "gender", + "sub", + "middle_name", + "given_name", + "nickname", + "preferred_username", + "name", + "updated_at", + "birthdate", + "locale", + "profile", + "family_name", + "picture", + "website", + } diff --git a/tests/test_23_oidc_registration_endpoint.py b/tests/test_23_oidc_registration_endpoint.py index eb2c23f2..f55e2bc8 100755 --- a/tests/test_23_oidc_registration_endpoint.py +++ b/tests/test_23_oidc_registration_endpoint.py @@ -178,6 +178,8 @@ def test_process_request(self): _reg_resp = _resp["response_args"] assert isinstance(_reg_resp, RegistrationResponse) assert "client_id" in _reg_resp and "client_secret" in _reg_resp + assert "response_code" in _resp + assert _resp["response_code"] == 201 def test_do_response(self): _req = self.endpoint.parse_request(CLI_REQ.to_json()) @@ -194,6 +196,7 @@ def test_do_response(self): assert isinstance(msg, dict) _msg = json.loads(msg["response"]) assert _msg + assert "response_code" in msg def test_register_unsupported_algo(self): _msg = MSG.copy() diff --git a/tests/test_24_oauth2_token_endpoint.py b/tests/test_24_oauth2_token_endpoint.py index e3627be5..b9eb6203 100644 --- a/tests/test_24_oauth2_token_endpoint.py +++ b/tests/test_24_oauth2_token_endpoint.py @@ -1,6 +1,7 @@ import json import os +import pytest from cryptojwt import JWT from cryptojwt.key_jar import build_keyjar from oidcmsg.oidc import AccessTokenRequest @@ -8,7 +9,6 @@ from oidcmsg.oidc import RefreshAccessTokenRequest from oidcmsg.oidc import TokenErrorResponse from oidcmsg.time_util import utc_time_sans_frac -import pytest from oidcop import JWT_BEARER from oidcop.authn_event import create_authn_event @@ -234,6 +234,28 @@ def test_parse(self): assert set(_req.keys()) == set(_token_request.keys()) + def test_auth_code_grant_disallowed_per_client(self): + areq = AUTH_REQ.copy() + areq["scope"] = ["email"] + self.endpoint_context.cdb["client_1"]["grant_types_supported"] = [] + + 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.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request(request=_req, issue_refresh=True) + + assert isinstance(_req, TokenErrorResponse) + assert _req.to_dict() == { + "error": "invalid_request", + "error_description": "Unsupported grant_type: authorization_code", + } + def test_process_request(self): session_id = self._create_session(AUTH_REQ) grant = self.session_manager[session_id] @@ -336,6 +358,24 @@ def test_do_refresh_access_token(self): msg = self.token_endpoint.do_response(request=_req, **_resp) assert isinstance(msg, dict) + def test_refresh_grant_disallowed_per_client(self): + areq = AUTH_REQ.copy() + areq["scope"] = ["email"] + self.endpoint_context.cdb["client_1"]["grant_types_supported"] = ["authorization_code"] + + 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.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request(request=_req, issue_refresh=True) + + assert "refresh_token" not in _resp + def test_do_2nd_refresh_access_token(self): areq = AUTH_REQ.copy() areq["scope"] = ["email"] @@ -344,6 +384,7 @@ def test_do_2nd_refresh_access_token(self): grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq["client_id"]) + self.token_endpoint.revoke_refresh_on_issue = False _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() @@ -369,8 +410,7 @@ def test_do_2nd_refresh_access_token(self): _2nd_request = REFRESH_TOKEN_REQ.copy() _2nd_request["refresh_token"] = _resp["response_args"]["refresh_token"] _2nd_req = self.token_endpoint.parse_request(_request.to_json()) - _2nd_resp = self.token_endpoint.process_request(request=_req, issue_refresh=True) - + _2nd_resp = self.token_endpoint.process_request(request=_2nd_req, issue_refresh=True) assert set(_2nd_resp.keys()) == {"cookie", "response_args", "http_headers"} assert set(_2nd_resp["response_args"].keys()) == { "access_token", @@ -421,6 +461,82 @@ def test_new_refresh_token(self, conf): assert first_refresh_token != second_refresh_token + def test_revoke_on_issue_refresh_token(self, conf): + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "client_salt": "salted", + "endpoint_auth_method": "client_secret_post", + "response_types": ["code", "token", "code id_token", "id_token"], + } + + self.token_endpoint.revoke_refresh_on_issue = True + areq = AUTH_REQ.copy() + areq["scope"] = ["email"] + + 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.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request(request=_req, issue_refresh=True) + assert "refresh_token" in _resp["response_args"] + first_refresh_token = _resp["response_args"]["refresh_token"] + + _refresh_request = REFRESH_TOKEN_REQ.copy() + _refresh_request["refresh_token"] = first_refresh_token + _2nd_req = self.token_endpoint.parse_request(_refresh_request.to_json()) + _2nd_resp = self.token_endpoint.process_request(request=_2nd_req, issue_refresh=True) + assert "refresh_token" in _2nd_resp["response_args"] + second_refresh_token = _2nd_resp["response_args"]["refresh_token"] + + assert first_refresh_token != second_refresh_token + first_refresh_token = grant.get_token(first_refresh_token) + second_refresh_token = grant.get_token(second_refresh_token) + assert first_refresh_token.revoked is True + assert second_refresh_token.revoked is False + + def test_revoke_on_issue_refresh_token_per_client(self, conf): + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "client_salt": "salted", + "endpoint_auth_method": "client_secret_post", + "response_types": ["code", "token", "code id_token", "id_token"], + } + self.endpoint_context.cdb[AUTH_REQ["client_id"]]["revoke_refresh_on_issue"] = True + areq = AUTH_REQ.copy() + areq["scope"] = ["openid", "offline_access"] + + 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.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request(request=_req, issue_refresh=True) + assert "refresh_token" in _resp["response_args"] + first_refresh_token = _resp["response_args"]["refresh_token"] + + _refresh_request = REFRESH_TOKEN_REQ.copy() + _refresh_request["refresh_token"] = first_refresh_token + _2nd_req = self.token_endpoint.parse_request(_refresh_request.to_json()) + _2nd_resp = self.token_endpoint.process_request(request=_2nd_req, issue_refresh=True) + assert "refresh_token" in _2nd_resp["response_args"] + second_refresh_token = _2nd_resp["response_args"]["refresh_token"] + + _2d_refresh_request = REFRESH_TOKEN_REQ.copy() + _2d_refresh_request["refresh_token"] = second_refresh_token + + assert first_refresh_token != second_refresh_token + first_refresh_token = grant.get_token(first_refresh_token) + second_refresh_token = grant.get_token(second_refresh_token) + assert first_refresh_token.revoked is True + assert second_refresh_token.revoked is False + def test_refresh_scopes(self): areq = AUTH_REQ.copy() areq["scope"] = ["email", "profile"] @@ -592,3 +708,53 @@ def test_configure_grant_types(self): assert len(self.token_endpoint.helper) == 1 assert "access_token" in self.token_endpoint.helper assert "refresh_token" not in self.token_endpoint.helper + + def test_token_request_other_client(self): + _context = self.endpoint_context + _context.cdb["client_2"] = _context.cdb["client_1"] + session_id = self._create_session(AUTH_REQ) + grant = self.session_manager[session_id] + code = self._mint_code(grant, AUTH_REQ["client_id"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["client_id"] = "client_2" + _token_request["code"] = code.value + + _req = self.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request(request=_req) + + assert isinstance(_resp, TokenErrorResponse) + assert _resp.to_dict() == { + "error": "invalid_grant", "error_description": "Wrong client" + } + + def test_refresh_token_request_other_client(self): + _context = self.endpoint_context + _context.cdb["client_2"] = _context.cdb["client_1"] + session_id = self._create_session(AUTH_REQ) + grant = self.session_manager[session_id] + code = self._mint_code(grant, AUTH_REQ["client_id"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + + _req = self.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request( + request=_req, issue_refresh=True + ) + + _request = REFRESH_TOKEN_REQ.copy() + _request["client_id"] = "client_2" + _request["refresh_token"] = _resp["response_args"]["refresh_token"] + + _token_value = _resp["response_args"]["refresh_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.usage_rules["supports_minting"] = ["access_token", "refresh_token"] + + _req = self.token_endpoint.parse_request(_request.to_json()) + _resp = self.token_endpoint.process_request(request=_req, ) + assert isinstance(_resp, TokenErrorResponse) + assert _resp.to_dict() == { + "error": "invalid_grant", "error_description": "Wrong client" + } diff --git a/tests/test_24_oidc_authorization_endpoint.py b/tests/test_24_oidc_authorization_endpoint.py index fa671abd..a13aa0c2 100755 --- a/tests/test_24_oidc_authorization_endpoint.py +++ b/tests/test_24_oidc_authorization_endpoint.py @@ -825,14 +825,18 @@ def test_verify_response_type(self): @pytest.mark.parametrize("exp_in", [360, "360", 0]) def test_mint_token_exp_at(self, exp_in): - grant = Grant() - grant.usage_rules = {"authorization_code": {"expires_in": exp_in}} - - DUMMY_SESSION_ID = self.session_manager.encrypted_session_id( - "user_id", "client_id", "grant.id" + request = AuthorizationRequest( + client_id="client_1", + response_type=["code"], + redirect_uri="https://example.com/cb", + state="state", + scope="openid", ) + sid = self._create_session(request) + grant = self.session_manager.get_grant(sid) + grant.usage_rules = {"authorization_code": {"expires_in": exp_in}} - code = self.endpoint.mint_token("authorization_code", grant, DUMMY_SESSION_ID) + code = self.endpoint.mint_token("authorization_code", grant, sid) if exp_in in [360, "360"]: assert code.expires_at else: @@ -894,7 +898,7 @@ def test_do_request_uri(self): def test_post_parse_request(self): endpoint_context = self.endpoint.server_get("endpoint_context") - msg = self.endpoint._post_parse_request(None, "client_1", endpoint_context) + msg = self.endpoint._post_parse_request({}, "client_1", endpoint_context) assert "error" in msg request = AuthorizationRequest( diff --git a/tests/test_26_oidc_userinfo_endpoint.py b/tests/test_26_oidc_userinfo_endpoint.py index cc7c082a..372d5261 100755 --- a/tests/test_26_oidc_userinfo_endpoint.py +++ b/tests/test_26_oidc_userinfo_endpoint.py @@ -1,11 +1,11 @@ import json import os +import pytest from oidcmsg.oauth2 import ResponseMessage from oidcmsg.oidc import AccessTokenRequest from oidcmsg.oidc import AuthorizationRequest from oidcmsg.time_util import time_sans_frac -import pytest from oidcop import user_info from oidcop.authn_event import create_authn_event @@ -17,6 +17,7 @@ from oidcop.oidc.provider_config import ProviderConfiguration from oidcop.oidc.registration import Registration from oidcop.oidc.token import Token +from oidcop.scopes import SCOPE2CLAIMS from oidcop.server import Server from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD from oidcop.user_info import UserInfo @@ -125,7 +126,7 @@ def create_endpoint(self): "class": userinfo.UserInfo, "kwargs": { "claim_types_supported": ["normal", "aggregated", "distributed", ], - "client_authn_method": ["bearer_header"], + "client_authn_method": ["bearer_header", "bearer_body"], }, }, }, @@ -148,35 +149,31 @@ def create_endpoint(self): }, "template_dir": "template", - "add_on": { - "custom_scopes": { - "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes", - "kwargs": { - "research_and_scholarship": [ - "name", - "given_name", - "family_name", - "email", - "email_verified", - "sub", - "eduperson_scoped_affiliation", - ] - }, - } + "scopes_to_claims": { + **SCOPE2CLAIMS, + "research_and_scholarship": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "eduperson_scoped_affiliation", + ], }, } - server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) + self.server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) - endpoint_context = server.endpoint_context - endpoint_context.cdb["client_1"] = { + self.endpoint_context = self.server.endpoint_context + self.endpoint_context.cdb["client_1"] = { "client_secret": "hemligt", "redirect_uris": [("https://example.com/cb", None)], "client_salt": "salted", "token_endpoint_auth_method": "client_secret_post", "response_types": ["code", "token", "code id_token", "id_token"], } - self.endpoint = server.server_get("endpoint", "userinfo") - self.session_manager = endpoint_context.session_manager + self.endpoint = self.server.server_get("endpoint", "userinfo") + self.session_manager = self.endpoint_context.session_manager self.user_id = "diana" def _create_session(self, auth_req, sub_type="public", sector_identifier="", @@ -320,7 +317,7 @@ def test_do_signed_response(self): res = self.endpoint.do_response(request=_req, **args) assert res - def test_custom_scope(self): + def test_scopes_to_claims(self): _auth_req = AUTH_REQ.copy() _auth_req["scope"] = ["openid", "research_and_scholarship"] @@ -350,6 +347,110 @@ def test_custom_scope(self): "sub", } + def test_scopes_to_claims_per_client(self): + self.endpoint_context.cdb["client_1"]["scopes_to_claims"] = { + **SCOPE2CLAIMS, + "research_and_scholarship_2": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "eduperson_scoped_affiliation", + ], + } + self.endpoint_context.cdb["client_1"]["allowed_scopes"] = list(self.endpoint_context.cdb["client_1"]["scopes_to_claims"].keys()) + ["aba"] + + _auth_req = AUTH_REQ.copy() + _auth_req["scope"] = ["openid", "research_and_scholarship_2", "aba"] + + session_id = self._create_session(_auth_req) + grant = self.session_manager[session_id] + access_token = self._mint_token("access_token", grant, session_id) + + self.endpoint.kwargs["add_claims_by_scope"] = True + self.endpoint.server_get("endpoint_context").claims_interface.add_claims_by_scope = True + grant.claims = { + "userinfo": self.endpoint.server_get("endpoint_context").claims_interface.get_claims( + session_id=session_id, scopes=_auth_req["scope"], claims_release_point="userinfo" + ) + } + + http_info = {"headers": {"authorization": "Bearer {}".format(access_token.value)}} + _req = self.endpoint.parse_request({}, http_info=http_info) + args = self.endpoint.process_request(_req, http_info=http_info) + + assert set(args["response_args"].keys()) == { + "eduperson_scoped_affiliation", + "given_name", + "email_verified", + "email", + "family_name", + "name", + "sub", + } + + def test_allowed_scopes(self): + self.endpoint_context.scopes_handler.allowed_scopes = list(SCOPE2CLAIMS.keys()) + + _auth_req = AUTH_REQ.copy() + _auth_req["scope"] = ["openid", "research_and_scholarship"] + + session_id = self._create_session(_auth_req) + grant = self.session_manager[session_id] + access_token = self._mint_token("access_token", grant, session_id) + + self.endpoint.kwargs["add_claims_by_scope"] = True + self.endpoint.server_get("endpoint_context").claims_interface.add_claims_by_scope = True + grant.claims = { + "userinfo": self.endpoint.server_get("endpoint_context").claims_interface.get_claims( + session_id=session_id, scopes=_auth_req["scope"], claims_release_point="userinfo" + ) + } + + http_info = {"headers": {"authorization": "Bearer {}".format(access_token.value)}} + _req = self.endpoint.parse_request({}, http_info=http_info) + args = self.endpoint.process_request(_req, http_info=http_info) + + assert set(args["response_args"].keys()) == {"sub"} + + def test_allowed_scopes_per_client(self): + self.endpoint_context.cdb["client_1"]["scopes_to_claims"] = { + **SCOPE2CLAIMS, + "research_and_scholarship_2": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "eduperson_scoped_affiliation", + ], + } + self.endpoint_context.cdb["client_1"]["allowed_scopes"] = list(SCOPE2CLAIMS.keys()) + + _auth_req = AUTH_REQ.copy() + _auth_req["scope"] = ["openid", "research_and_scholarship_2"] + + session_id = self._create_session(_auth_req) + grant = self.session_manager[session_id] + access_token = self._mint_token("access_token", grant, session_id) + + self.endpoint.kwargs["add_claims_by_scope"] = True + self.endpoint.server_get("endpoint_context").claims_interface.add_claims_by_scope = True + grant.claims = { + "userinfo": self.endpoint.server_get("endpoint_context").claims_interface.get_claims( + session_id=session_id, scopes=_auth_req["scope"], claims_release_point="userinfo" + ) + } + + http_info = {"headers": {"authorization": "Bearer {}".format(access_token.value)}} + _req = self.endpoint.parse_request({}, http_info=http_info) + args = self.endpoint.process_request(_req, http_info=http_info) + + assert set(args["response_args"].keys()) == {"sub"} + def test_wrong_type_of_token(self): _auth_req = AUTH_REQ.copy() _auth_req["scope"] = ["openid", "research_and_scholarship"] @@ -382,6 +483,22 @@ def test_invalid_token(self): assert isinstance(args, ResponseMessage) assert args["error_description"] == "Invalid Token" + def test_invalid_token_2(self): + _auth_req = AUTH_REQ.copy() + _auth_req["scope"] = ["openid", "research_and_scholarship"] + + session_id = self._create_session(_auth_req) + grant = self.session_manager[session_id] + access_token = self._mint_token("access_token", grant, session_id) + self.session_manager.flush() + + http_info = {"headers": {"authorization": "Bearer {}".format(access_token.value)}} + _req = self.endpoint.parse_request({}, http_info=http_info) + args = self.endpoint.process_request(_req) + + assert isinstance(args, ResponseMessage) + assert args["error_description"] == "Invalid Token" + def test_expired_token(self, monkeypatch): _auth_req = AUTH_REQ.copy() _auth_req["scope"] = ["openid", "research_and_scholarship"] @@ -441,6 +558,23 @@ def test_userinfo_claims_acr_none(self): _response = json.loads(res["response"]) assert _response["acr"] == _acr + def test_userinfo_claims_post(self): + _acr = "https://refeds.org/profile/mfa" + _auth_req = AUTH_REQ.copy() + _auth_req["claims"] = {"userinfo": {"acr": {"value": _acr}}} + + session_id = self._create_session(_auth_req, authn_info=_acr) + grant = self.session_manager[session_id] + code = self._mint_code(grant, session_id) + access_token = self._mint_token("access_token", grant, session_id, code) + + _req = self.endpoint.parse_request({"access_token": access_token.value}) + args = self.endpoint.process_request(_req) + assert args + res = self.endpoint.do_response(request=_req, **args) + _response = json.loads(res["response"]) + assert _response["acr"] == _acr + def test_process_request_absent_userinfo_conf(self): # consider to have a configuration without userinfo defined in ec = self.endpoint.server_get('endpoint_context') diff --git a/tests/test_35_oidc_token_endpoint.py b/tests/test_35_oidc_token_endpoint.py index 998fbfab..66dd6ec0 100755 --- a/tests/test_35_oidc_token_endpoint.py +++ b/tests/test_35_oidc_token_endpoint.py @@ -29,6 +29,7 @@ from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD from oidcop.user_info import UserInfo from oidcop.util import lv_pack +from tests.test_24_oauth2_token_endpoint import TestEndpoint as _TestEndpoint KEYDEFS = [ {"type": "RSA", "key": "", "use": ["sig"]}, @@ -116,7 +117,7 @@ def conf(): }, "refresh": { "class": "oidcop.token.jwt_token.JWTToken", - "kwargs": {"lifetime": 3600, "aud": ["https://example.org/appl"], }, + "kwargs": {"lifetime": 3600, "aud": ["https://example.org/appl"]}, }, "id_token": {"class": "oidcop.token.id_token.IDToken", "kwargs": {}}, }, @@ -183,7 +184,7 @@ def conf(): } -class TestEndpoint(object): +class TestEndpoint(_TestEndpoint): @pytest.fixture(autouse=True) def create_endpoint(self, conf): server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) @@ -283,6 +284,7 @@ def test_process_request(self): assert _resp assert set(_resp.keys()) == {"cookie", "http_headers", "response_args"} + assert "expires_in" in _resp["response_args"] def test_process_request_using_code_twice(self): session_id = self._create_session(AUTH_REQ) @@ -388,7 +390,7 @@ def test_do_2nd_refresh_access_token(self): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) code = self._mint_code(grant, areq["client_id"]) - + self.token_endpoint.revoke_refresh_on_issue = False _cntx = self.endpoint_context _token_request = TOKEN_REQ_DICT.copy() @@ -759,6 +761,84 @@ def test_new_refresh_token(self, conf): assert first_refresh_token != second_refresh_token + def test_revoke_on_issue_refresh_token(self, conf): + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "client_salt": "salted", + "endpoint_auth_method": "client_secret_post", + "response_types": ["code", "token", "code id_token", "id_token"], + } + self.token_endpoint.revoke_refresh_on_issue = True + areq = AUTH_REQ.copy() + areq["scope"] = ["openid", "offline_access"] + + 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.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request(request=_req, issue_refresh=True) + assert "refresh_token" in _resp["response_args"] + first_refresh_token = _resp["response_args"]["refresh_token"] + + _refresh_request = REFRESH_TOKEN_REQ.copy() + _refresh_request["refresh_token"] = first_refresh_token + _2nd_req = self.token_endpoint.parse_request(_refresh_request.to_json()) + _2nd_resp = self.token_endpoint.process_request(request=_2nd_req, issue_refresh=True) + assert "refresh_token" in _2nd_resp["response_args"] + second_refresh_token = _2nd_resp["response_args"]["refresh_token"] + + _2d_refresh_request = REFRESH_TOKEN_REQ.copy() + _2d_refresh_request["refresh_token"] = second_refresh_token + + assert first_refresh_token != second_refresh_token + first_refresh_token = grant.get_token(first_refresh_token) + second_refresh_token = grant.get_token(second_refresh_token) + assert first_refresh_token.revoked is True + assert second_refresh_token.revoked is False + + def test_revoke_on_issue_refresh_token_per_client(self, conf): + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "client_salt": "salted", + "endpoint_auth_method": "client_secret_post", + "response_types": ["code", "token", "code id_token", "id_token"], + } + self.endpoint_context.cdb[AUTH_REQ["client_id"]]["revoke_refresh_on_issue"] = True + areq = AUTH_REQ.copy() + areq["scope"] = ["openid", "offline_access"] + + 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.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request(request=_req, issue_refresh=True) + assert "refresh_token" in _resp["response_args"] + first_refresh_token = _resp["response_args"]["refresh_token"] + + _refresh_request = REFRESH_TOKEN_REQ.copy() + _refresh_request["refresh_token"] = first_refresh_token + _2nd_req = self.token_endpoint.parse_request(_refresh_request.to_json()) + _2nd_resp = self.token_endpoint.process_request(request=_2nd_req, issue_refresh=True) + assert "refresh_token" in _2nd_resp["response_args"] + second_refresh_token = _2nd_resp["response_args"]["refresh_token"] + + _2d_refresh_request = REFRESH_TOKEN_REQ.copy() + _2d_refresh_request["refresh_token"] = second_refresh_token + + assert first_refresh_token != second_refresh_token + first_refresh_token = grant.get_token(first_refresh_token) + second_refresh_token = grant.get_token(second_refresh_token) + assert first_refresh_token.revoked is True + assert second_refresh_token.revoked is False + def test_do_refresh_access_token_not_allowed(self): areq = AUTH_REQ.copy() areq["scope"] = ["openid", "offline_access"] @@ -836,6 +916,55 @@ def test_access_token_lifetime(self): assert access_token["exp"] - access_token["iat"] == lifetime + def test_token_request_other_client(self): + _context = self.endpoint_context + _context.cdb["client_2"] = _context.cdb["client_1"] + session_id = self._create_session(AUTH_REQ) + grant = self.session_manager[session_id] + code = self._mint_code(grant, AUTH_REQ["client_id"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["client_id"] = "client_2" + _token_request["code"] = code.value + + _req = self.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request(request=_req) + + assert isinstance(_resp, TokenErrorResponse) + assert _resp.to_dict() == { + "error": "invalid_grant", "error_description": "Wrong client" + } + + def test_refresh_token_request_other_client(self): + _context = self.endpoint_context + _context.cdb["client_2"] = _context.cdb["client_1"] + session_id = self._create_session(AUTH_REQ) + grant = self.session_manager[session_id] + code = self._mint_code(grant, AUTH_REQ["client_id"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + + _req = self.token_endpoint.parse_request(_token_request) + _resp = self.token_endpoint.process_request( + request=_req, issue_refresh=True + ) + + _request = REFRESH_TOKEN_REQ.copy() + _request["client_id"] = "client_2" + _request["refresh_token"] = _resp["response_args"]["refresh_token"] + + _token_value = _resp["response_args"]["refresh_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.usage_rules["supports_minting"] = ["access_token", "refresh_token"] + + _req = self.token_endpoint.parse_request(_request.to_json()) + _resp = self.token_endpoint.process_request(request=_req,) + assert isinstance(_resp, TokenErrorResponse) + assert _resp.to_dict() == { + "error": "invalid_grant", "error_description": "Wrong client" + } class TestOldTokens(object): @pytest.fixture(autouse=True) diff --git a/tests/test_50_persistence.py b/tests/test_50_persistence.py index 9052dd27..d53148fc 100644 --- a/tests/test_50_persistence.py +++ b/tests/test_50_persistence.py @@ -2,10 +2,10 @@ import os import shutil +import pytest from cryptojwt.jwt import utc_time_sans_frac from oidcmsg.oidc import AccessTokenRequest from oidcmsg.oidc import AuthorizationRequest -import pytest from oidcop import user_info from oidcop.authn_event import create_authn_event @@ -16,6 +16,7 @@ from oidcop.oidc.provider_config import ProviderConfiguration from oidcop.oidc.registration import Registration from oidcop.oidc.token import Token +from oidcop.scopes import SCOPE2CLAIMS from oidcop.server import Server from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD from oidcop.user_info import UserInfo @@ -136,21 +137,17 @@ def full_path(local_file): } }, "template_dir": "template", - "add_on": { - "custom_scopes": { - "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes", - "kwargs": { - "research_and_scholarship": [ - "name", - "given_name", - "family_name", - "email", - "email_verified", - "sub", - "eduperson_scoped_affiliation", - ] - }, - } + "scopes_to_claims": { + **SCOPE2CLAIMS, + "research_and_scholarship": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "eduperson_scoped_affiliation", + ], }, "authz": { "class": AuthzHandling, @@ -401,8 +398,8 @@ def test_custom_scope(self): _auth_req = AUTH_REQ.copy() _auth_req["scope"] = ["openid", "research_and_scholarship"] - session_id = self._create_session(AUTH_REQ, index=2) - grant = self.endpoint[2].server_get("endpoint_context").authz(session_id, AUTH_REQ) + session_id = self._create_session(_auth_req, index=2) + grant = self.endpoint[2].server_get("endpoint_context").authz(session_id, _auth_req) self._dump_restore(2, 1)