def test_rejects_mismatching_request(self, client_preference, provider_capability, client_value, provider_value): request = {'redirect_uris': ['https://client.example.com/redirect']} provider_capabilities = provide_configuration() if client_preference.startswith( ('request_object_encryption', 'id_token_encrypted', 'userinfo_encrypted')): # provide default value for the metadata params that come in pairs param = client_preference[:-4] alg_param = param + '_alg' request[alg_param] = 'RSA-OAEP' provider_capabilities[PREFERENCE2PROVIDER[alg_param]] = [ 'RSA-OAEP' ] enc_param = param + '_enc' request[enc_param] = 'A192CBC-HS256' provider_capabilities[PREFERENCE2PROVIDER[enc_param]] = [ 'A192CBC-HS256' ] request[client_preference] = client_value provider_capabilities[provider_capability] = provider_value provider = Provider( rsa_key(), provider_capabilities, AuthorizationState(HashBasedSubjectIdentifierFactory('salt')), {}, None) with pytest.raises(InvalidClientRegistrationRequest): provider.handle_client_registration_request(json.dumps(request))
def test_matches_common_set_of_metadata_values(self, client_preference, provider_capability, client_value, provider_value): provider_capabilities = provide_configuration() provider_capabilities.update({provider_capability: provider_value}) provider = Provider( rsa_key(), provider_capabilities, AuthorizationState(HashBasedSubjectIdentifierFactory('salt')), {}, None) request = { 'redirect_uris': ['https://client.example.com/redirect'], client_preference: client_value } response = provider.handle_client_registration_request( json.dumps(request)) expected_values = set(client_value).intersection(provider_value) assert Counter(frozenset(v.split()) for v in response[client_preference]) == \ Counter(frozenset(v.split()) for v in expected_values)
class OpenIDConnectFrontend(FrontendModule): """ A OpenID Connect frontend module """ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, name): self._validate_config(conf) super().__init__(auth_req_callback_func, internal_attributes, base_url, name) self.config = conf self.signing_key = RSAKey(key=rsa_load(conf["signing_key_path"]), use="sig", alg="RS256") def _create_provider(self, endpoint_baseurl): response_types_supported = self.config["provider"].get( "response_types_supported", ["id_token"]) subject_types_supported = self.config["provider"].get( "subject_types_supported", ["pairwise"]) scopes_supported = self.config["provider"].get("scopes_supported", ["openid"]) capabilities = { "issuer": self.base_url, "authorization_endpoint": "{}/{}".format(endpoint_baseurl, AuthorizationEndpoint.url), "jwks_uri": "{}/jwks".format(endpoint_baseurl), "response_types_supported": response_types_supported, "id_token_signing_alg_values_supported": [self.signing_key.alg], "response_modes_supported": ["fragment", "query"], "subject_types_supported": subject_types_supported, "claim_types_supported": ["normal"], "claims_parameter_supported": True, "claims_supported": [ attribute_map["openid"][0] for attribute_map in self.internal_attributes["attributes"].values() if "openid" in attribute_map ], "request_parameter_supported": False, "request_uri_parameter_supported": False, "scopes_supported": scopes_supported } if 'code' in response_types_supported: capabilities["token_endpoint"] = "{}/{}".format( endpoint_baseurl, TokenEndpoint.url) if self.config["provider"].get("client_registration_supported", False): capabilities["registration_endpoint"] = "{}/{}".format( endpoint_baseurl, RegistrationEndpoint.url) authz_state = self._init_authorization_state() db_uri = self.config.get("db_uri") cdb_file = self.config.get("client_db_path") if db_uri: cdb = MongoWrapper(db_uri, "satosa", "clients") elif cdb_file: with open(cdb_file) as f: cdb = json.loads(f.read()) else: cdb = {} self.user_db = MongoWrapper(db_uri, "satosa", "authz_codes") if db_uri else {} self.provider = Provider(self.signing_key, capabilities, authz_state, cdb, Userinfo(self.user_db)) def _init_authorization_state(self): sub_hash_salt = self.config.get("sub_hash_salt", rndstr(16)) db_uri = self.config.get("db_uri") if db_uri: authz_code_db = MongoWrapper(db_uri, "satosa", "authz_codes") access_token_db = MongoWrapper(db_uri, "satosa", "access_tokens") refresh_token_db = MongoWrapper(db_uri, "satosa", "refresh_tokens") sub_db = MongoWrapper(db_uri, "satosa", "subject_identifiers") else: authz_code_db = None access_token_db = None refresh_token_db = None sub_db = None token_lifetimes = { k: self.config["provider"][k] for k in [ "authorization_code_lifetime", "access_token_lifetime", "refresh_token_lifetime", "refresh_token_threshold" ] if k in self.config["provider"] } return AuthorizationState( HashBasedSubjectIdentifierFactory(sub_hash_salt), authz_code_db, access_token_db, refresh_token_db, sub_db, **token_lifetimes) def handle_authn_response(self, context, internal_resp, extra_id_token_claims=None): """ See super class method satosa.frontends.base.FrontendModule#handle_authn_response :type context: satosa.context.Context :type internal_response: satosa.internal.InternalData :rtype oic.utils.http_util.Response """ auth_req = self._get_authn_request_from_state(context.state) attributes = self.converter.from_internal("openid", internal_resp.attributes) self.user_db[internal_resp.subject_id] = { k: v[0] for k, v in attributes.items() } auth_resp = self.provider.authorize(auth_req, internal_resp.subject_id, extra_id_token_claims) del context.state[self.name] http_response = auth_resp.request(auth_req["redirect_uri"], should_fragment_encode(auth_req)) return SeeOther(http_response) def handle_backend_error(self, exception): """ See super class satosa.frontends.base.FrontendModule :type exception: satosa.exception.SATOSAError :rtype: oic.utils.http_util.Response """ auth_req = self._get_authn_request_from_state(exception.state) # If the client sent us a state parameter, we should reflect it back according to the spec if 'state' in auth_req: error_resp = AuthorizationErrorResponse( error="access_denied", error_description=exception.message, state=auth_req['state']) else: error_resp = AuthorizationErrorResponse( error="access_denied", error_description=exception.message) satosa_logging(logger, logging.DEBUG, exception.message, exception.state) return SeeOther( error_resp.request(auth_req["redirect_uri"], should_fragment_encode(auth_req))) def register_endpoints(self, backend_names): """ See super class satosa.frontends.base.FrontendModule :type backend_names: list[str] :rtype: list[(str, ((satosa.context.Context, Any) -> satosa.response.Response, Any))] :raise ValueError: if more than one backend is configured """ backend_name = None if len(backend_names) != 1: # only supports one backend since there currently is no way to publish multiple authorization endpoints # in configuration information and there is no other standard way of authorization_endpoint discovery # similar to SAML entity discovery # this can be circumvented with a custom RequestMicroService which handles the routing based on something # in the authentication request logger.warn( "More than one backend is configured, make sure to provide a custom routing micro service to " "determine which backend should be used per request.") else: backend_name = backend_names[0] endpoint_baseurl = "{}/{}".format(self.base_url, self.name) self._create_provider(endpoint_baseurl) provider_config = ("^.well-known/openid-configuration$", self.provider_config) jwks_uri = ("^{}/jwks$".format(self.name), self.jwks) if backend_name: # if there is only one backend, include its name in the path so the default routing can work auth_endpoint = "{}/{}/{}/{}".format(self.base_url, backend_name, self.name, AuthorizationEndpoint.url) self.provider.configuration_information[ "authorization_endpoint"] = auth_endpoint auth_path = urlparse(auth_endpoint).path.lstrip("/") else: auth_path = "{}/{}".format(self.name, AuthorizationEndpoint.url) authentication = ("^{}$".format(auth_path), self.handle_authn_request) url_map = [provider_config, jwks_uri, authentication] if any("code" in v for v in self.provider. configuration_information["response_types_supported"]): self.provider.configuration_information[ "token_endpoint"] = "{}/{}".format(endpoint_baseurl, TokenEndpoint.url) token_endpoint = ("^{}/{}".format(self.name, TokenEndpoint.url), self.token_endpoint) url_map.append(token_endpoint) self.provider.configuration_information[ "userinfo_endpoint"] = "{}/{}".format(endpoint_baseurl, UserinfoEndpoint.url) userinfo_endpoint = ("^{}/{}".format(self.name, UserinfoEndpoint.url), self.userinfo_endpoint) url_map.append(userinfo_endpoint) if "registration_endpoint" in self.provider.configuration_information: client_registration = ("^{}/{}".format(self.name, RegistrationEndpoint.url), self.client_registration) url_map.append(client_registration) return url_map def _validate_config(self, config): """ Validates that all necessary config parameters are specified. :type config: dict[str, dict[str, Any] | str] :param config: the module config """ if config is None: raise ValueError("OIDCFrontend conf can't be 'None'.") for k in {"signing_key_path", "provider"}: if k not in config: raise ValueError( "Missing configuration parameter '{}' for OpenID Connect frontend." .format(k)) def _get_authn_request_from_state(self, state): """ Extract the clietns request stoed in the SATOSA state. :type state: satosa.state.State :rtype: oic.oic.message.AuthorizationRequest :param state: the current state :return: the parsed authentication request """ return AuthorizationRequest().deserialize( state[self.name]["oidc_request"]) def client_registration(self, context): """ Handle the OIDC dynamic client registration. :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ try: resp = self.provider.handle_client_registration_request( json.dumps(context.request)) return Created(resp.to_json(), content="application/json") except InvalidClientRegistrationRequest as e: return BadRequest(e.to_json(), content="application/json") def provider_config(self, context): """ Construct the provider configuration information (served at /.well-known/openid-configuration). :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ return Response(self.provider.provider_configuration.to_json(), content="application/json") def _get_approved_attributes(self, provider_supported_claims, authn_req): requested_claims = list(scope2claims(authn_req["scope"]).keys()) if "claims" in authn_req: for k in ["id_token", "userinfo"]: if k in authn_req["claims"]: requested_claims.extend(authn_req["claims"][k].keys()) return set(provider_supported_claims).intersection( set(requested_claims)) def _handle_authn_request(self, context): """ Parse and verify the authentication request into an internal request. :type context: satosa.context.Context :rtype: satosa.internal.InternalData :param context: the current context :return: the internal request """ request = urlencode(context.request) satosa_logging(logger, logging.DEBUG, "Authn req from client: {}".format(request), context.state) try: authn_req = self.provider.parse_authentication_request(request) except InvalidAuthenticationRequest as e: satosa_logging(logger, logging.ERROR, "Error in authn req: {}".format(str(e)), context.state) error_url = e.to_error_url() if error_url: return SeeOther(error_url) else: return BadRequest("Something went wrong: {}".format(str(e))) client_id = authn_req["client_id"] context.state[self.name] = {"oidc_request": request} subject_type = self.provider.clients[client_id].get( "subject_type", "pairwise") client_name = self.provider.clients[client_id].get("client_name") if client_name: # TODO should process client names for all languages, see OIDC Registration, Section 2.1 requester_name = [{"lang": "en", "text": client_name}] else: requester_name = None internal_req = InternalData( subject_type=subject_type, requester=client_id, requester_name=requester_name, ) internal_req.attributes = self.converter.to_internal_filter( "openid", self._get_approved_attributes( self.provider.configuration_information["claims_supported"], authn_req)) return internal_req def handle_authn_request(self, context): """ Handle an authentication request and pass it on to the backend. :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ internal_req = self._handle_authn_request(context) if not isinstance(internal_req, InternalData): return internal_req return self.auth_req_callback_func(context, internal_req) def jwks(self, context): """ Construct the JWKS document (served at /jwks). :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ return Response(json.dumps(self.provider.jwks), content="application/json") def token_endpoint(self, context): """ Handle token requests (served at /token). :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ headers = {"Authorization": context.request_authorization} try: response = self.provider.handle_token_request( urlencode(context.request), headers) return Response(response.to_json(), content="application/json") except InvalidClientAuthentication as e: logger.debug('invalid client authentication at token endpoint', exc_info=True) error_resp = TokenErrorResponse(error='invalid_client', error_description=str(e)) response = Unauthorized(error_resp.to_json(), headers=[("WWW-Authenticate", "Basic")], content="application/json") return response except OAuthError as e: logger.debug('invalid request: %s', str(e), exc_info=True) error_resp = TokenErrorResponse(error=e.oauth_error, error_description=str(e)) return BadRequest(error_resp.to_json(), content="application/json") def userinfo_endpoint(self, context): headers = {"Authorization": context.request_authorization} try: response = self.provider.handle_userinfo_request( urlencode(context.request), headers) return Response(response.to_json(), content="application/json") except (BearerTokenError, InvalidAccessToken) as e: error_resp = UserInfoErrorResponse(error='invalid_token', error_description=str(e)) response = Unauthorized(error_resp.to_json(), headers=[("WWW-Authenticate", AccessToken.BEARER_TOKEN_TYPE)], content="application/json") return response
class OpenIDConnectFrontend(FrontendModule): """ A OpenID Connect frontend module """ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, name): self._validate_config(conf) super().__init__(auth_req_callback_func, internal_attributes, base_url, name) self.config = conf self.signing_key = RSAKey(key=rsa_load(conf["signing_key_path"]), use="sig", alg="RS256") def _create_provider(self, endpoint_baseurl): response_types_supported = self.config["provider"].get("response_types_supported", ["id_token"]) subject_types_supported = self.config["provider"].get("subject_types_supported", ["pairwise"]) scopes_supported = self.config["provider"].get("scopes_supported", ["openid"]) capabilities = { "issuer": self.base_url, "authorization_endpoint": "{}/{}".format(endpoint_baseurl, AuthorizationEndpoint.url), "jwks_uri": "{}/jwks".format(endpoint_baseurl), "response_types_supported": response_types_supported, "id_token_signing_alg_values_supported": [self.signing_key.alg], "response_modes_supported": ["fragment", "query"], "subject_types_supported": subject_types_supported, "claim_types_supported": ["normal"], "claims_parameter_supported": True, "claims_supported": [ attribute_map["openid"][0] for attribute_map in self.internal_attributes["attributes"].values() if "openid" in attribute_map ], "request_parameter_supported": False, "request_uri_parameter_supported": False, "scopes_supported": scopes_supported, } if self.config["provider"].get("client_registration_supported", False): capabilities["registration_endpoint"] = "{}/{}".format(endpoint_baseurl, RegistrationEndpoint.url) authz_state = self._init_authorization_state() db_uri = self.config.get("db_uri") cdb = MongoWrapper(db_uri, "satosa", "clients") if db_uri else {} self.user_db = MongoWrapper(db_uri, "satosa", "authz_codes") if db_uri else {} self.provider = Provider(self.signing_key, capabilities, authz_state, cdb, Userinfo(self.user_db)) def _init_authorization_state(self): sub_hash_salt = self.config.get("sub_hash_salt", rndstr(16)) db_uri = self.config.get("db_uri") if db_uri: authz_code_db = MongoWrapper(db_uri, "satosa", "authz_codes") access_token_db = MongoWrapper(db_uri, "satosa", "access_tokens") refresh_token_db = MongoWrapper(db_uri, "satosa", "refresh_tokens") sub_db = MongoWrapper(db_uri, "satosa", "subject_identifiers") else: authz_code_db = None access_token_db = None refresh_token_db = None sub_db = None token_lifetimes = { k: self.config["provider"][k] for k in [ "authorization_code_lifetime", "access_token_lifetime", "refresh_token_lifetime", "refresh_token_threshold", ] if k in self.config["provider"] } return AuthorizationState( HashBasedSubjectIdentifierFactory(sub_hash_salt), authz_code_db, access_token_db, refresh_token_db, sub_db, **token_lifetimes ) def handle_authn_response(self, context, internal_resp, extra_id_token_claims=None): """ See super class method satosa.frontends.base.FrontendModule#handle_authn_response :type context: satosa.context.Context :type internal_response: satosa.internal_data.InternalResponse :rtype oic.utils.http_util.Response """ auth_req = self._get_authn_request_from_state(context.state) attributes = self.converter.from_internal("openid", internal_resp.attributes) self.user_db[internal_resp.user_id] = {k: v[0] for k, v in attributes.items()} auth_resp = self.provider.authorize(auth_req, internal_resp.user_id, extra_id_token_claims) del context.state[self.name] http_response = auth_resp.request(auth_req["redirect_uri"], should_fragment_encode(auth_req)) return SeeOther(http_response) def handle_backend_error(self, exception): """ See super class satosa.frontends.base.FrontendModule :type exception: satosa.exception.SATOSAError :rtype: oic.utils.http_util.Response """ auth_req = self._get_authn_request_from_state(exception.state) error_resp = AuthorizationErrorResponse(error="access_denied", error_description=exception.message) satosa_logging(logger, logging.DEBUG, exception.message, exception.state) return SeeOther(error_resp.request(auth_req["redirect_uri"], should_fragment_encode(auth_req))) def register_endpoints(self, backend_names): """ See super class satosa.frontends.base.FrontendModule :type backend_names: list[str] :rtype: list[(str, ((satosa.context.Context, Any) -> satosa.response.Response, Any))] :raise ValueError: if more than one backend is configured """ backend_name = None if len(backend_names) != 1: # only supports one backend since there currently is no way to publish multiple authorization endpoints # in configuration information and there is no other standard way of authorization_endpoint discovery # similar to SAML entity discovery # this can be circumvented with a custom RequestMicroService which handles the routing based on something # in the authentication request logger.warn( "More than one backend is configured, make sure to provide a custom routing micro service to " "determine which backend should be used per request." ) else: backend_name = backend_names[0] endpoint_baseurl = "{}/{}".format(self.base_url, self.name) self._create_provider(endpoint_baseurl) provider_config = ("^.well-known/openid-configuration$", self.provider_config) jwks_uri = ("^{}/jwks$".format(self.name), self.jwks) if backend_name: # if there is only one backend, include its name in the path so the default routing can work auth_endpoint = "{}/{}/{}/{}".format(self.base_url, backend_name, self.name, AuthorizationEndpoint.url) self.provider.configuration_information["authorization_endpoint"] = auth_endpoint auth_path = urlparse(auth_endpoint).path.lstrip("/") else: auth_path = "{}/{}".format(self.name, AuthorizationEndpoint.url) authentication = ("^{}$".format(auth_path), self.handle_authn_request) url_map = [provider_config, jwks_uri, authentication] if any("code" in v for v in self.provider.configuration_information["response_types_supported"]): self.provider.configuration_information["token_endpoint"] = "{}/{}".format( endpoint_baseurl, TokenEndpoint.url ) token_endpoint = ("^{}/{}".format(self.name, TokenEndpoint.url), self.token_endpoint) url_map.append(token_endpoint) self.provider.configuration_information["userinfo_endpoint"] = "{}/{}".format( endpoint_baseurl, UserinfoEndpoint.url ) userinfo_endpoint = ("^{}/{}".format(self.name, UserinfoEndpoint.url), self.userinfo_endpoint) url_map.append(userinfo_endpoint) if "registration_endpoint" in self.provider.configuration_information: client_registration = ("^{}/{}".format(self.name, RegistrationEndpoint.url), self.client_registration) url_map.append(client_registration) return url_map def _validate_config(self, config): """ Validates that all necessary config parameters are specified. :type config: dict[str, dict[str, Any] | str] :param config: the module config """ if config is None: raise ValueError("OIDCFrontend conf can't be 'None'.") for k in {"signing_key_path", "provider"}: if k not in config: raise ValueError("Missing configuration parameter '{}' for OpenID Connect frontend.".format(k)) def _get_authn_request_from_state(self, state): """ Extract the clietns request stoed in the SATOSA state. :type state: satosa.state.State :rtype: oic.oic.message.AuthorizationRequest :param state: the current state :return: the parsed authentication request """ return AuthorizationRequest().deserialize(state[self.name]["oidc_request"]) def client_registration(self, context): """ Handle the OIDC dynamic client registration. :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ try: resp = self.provider.handle_client_registration_request(json.dumps(context.request)) return Created(resp.to_json(), content="application/json") except InvalidClientRegistrationRequest as e: return BadRequest(e.to_json(), content="application/json") def provider_config(self, context): """ Construct the provider configuration information (served at /.well-known/openid-configuration). :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ return Response(self.provider.provider_configuration.to_json(), content="application/json") def _get_approved_attributes(self, provider_supported_claims, authn_req): requested_claims = list(scope2claims(authn_req["scope"]).keys()) if "claims" in authn_req: for k in ["id_token", "userinfo"]: if k in authn_req["claims"]: requested_claims.extend(authn_req["claims"][k].keys()) return set(provider_supported_claims).intersection(set(requested_claims)) def _handle_authn_request(self, context): """ Parse and verify the authentication request into an internal request. :type context: satosa.context.Context :rtype: internal_data.InternalRequest :param context: the current context :return: the internal request """ request = urlencode(context.request) satosa_logging(logger, logging.DEBUG, "Authn req from client: {}".format(request), context.state) try: authn_req = self.provider.parse_authentication_request(request) except InvalidAuthenticationRequest as e: satosa_logging(logger, logging.ERROR, "Error in authn req: {}".format(str(e)), context.state) error_url = e.to_error_url() if error_url: return SeeOther(error_url) else: return BadRequest("Something went wrong: {}".format(str(e))) client_id = authn_req["client_id"] context.state[self.name] = {"oidc_request": request} hash_type = oidc_subject_type_to_hash_type(self.provider.clients[client_id].get("subject_type", "pairwise")) client_name = self.provider.clients[client_id].get("client_name") if client_name: # TODO should process client names for all languages, see OIDC Registration, Section 2.1 requester_name = [{"lang": "en", "text": client_name}] else: requester_name = None internal_req = InternalRequest(hash_type, client_id, requester_name) internal_req.approved_attributes = self.converter.to_internal_filter( "openid", self._get_approved_attributes(self.provider.configuration_information["claims_supported"], authn_req), ) return internal_req def handle_authn_request(self, context): """ Handle an authentication request and pass it on to the backend. :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ internal_req = self._handle_authn_request(context) if not isinstance(internal_req, InternalRequest): return internal_req return self.auth_req_callback_func(context, internal_req) def jwks(self, context): """ Construct the JWKS document (served at /jwks). :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ return Response(json.dumps(self.provider.jwks), content="application/json") def token_endpoint(self, context): """ Handle token requests (served at /token). :type context: satosa.context.Context :rtype: oic.utils.http_util.Response :param context: the current context :return: HTTP response to the client """ headers = {"Authorization": context.request_authorization} try: response = self.provider.handle_token_request(urlencode(context.request), headers) return Response(response.to_json(), content="application/json") except InvalidClientAuthentication as e: logger.debug("invalid client authentication at token endpoint", exc_info=True) error_resp = TokenErrorResponse(error="invalid_client", error_description=str(e)) response = Unauthorized( error_resp.to_json(), headers=[("WWW-Authenticate", "Basic")], content="application/json" ) return response except OAuthError as e: logger.debug("invalid request: %s", str(e), exc_info=True) error_resp = TokenErrorResponse(error=e.oauth_error, error_description=str(e)) return BadRequest(error_resp.to_json(), content="application/json") def userinfo_endpoint(self, context): headers = {"Authorization": context.request_authorization} try: response = self.provider.handle_userinfo_request(urlencode(context.request), headers) return Response(response.to_json(), content="application/json") except (BearerTokenError, InvalidAccessToken) as e: error_resp = UserInfoErrorResponse(error="invalid_token", error_description=str(e)) response = Unauthorized( error_resp.to_json(), headers=[("WWW-Authenticate", AccessToken.BEARER_TOKEN_TYPE)], content="application/json", ) return response