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 _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 _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_oidc_provider(app): with app.app_context(): issuer = url_for('oidc_provider.index')[:-1] authentication_endpoint = url_for('oidc_provider.authentication_endpoint') jwks_uri = url_for('oidc_provider.jwks_uri') token_endpoint = url_for('oidc_provider.token_endpoint') userinfo_endpoint = url_for('oidc_provider.userinfo_endpoint') registration_endpoint = url_for('oidc_provider.registration_endpoint') end_session_endpoint = url_for('oidc_provider.end_session_endpoint') userinfo_ldap = LdapUserInfo() configuration_information = { 'issuer': issuer, 'authorization_endpoint': authentication_endpoint, 'jwks_uri': jwks_uri, 'token_endpoint': token_endpoint, 'userinfo_endpoint': userinfo_endpoint, 'registration_endpoint': registration_endpoint, 'end_session_endpoint': end_session_endpoint, 'scopes_supported': ['openid', 'profile'], 'response_types_supported': ['code', 'code id_token', 'code token', 'code id_token token'], # code and hybrid 'response_modes_supported': ['query', 'fragment'], 'grant_types_supported': ['authorization_code', 'implicit'], 'subject_types_supported': ['pairwise'], 'token_endpoint_auth_methods_supported': ['client_secret_basic'], 'claims_parameter_supported': True } signing_key = RSAKey(key=rsa_load('signing_key.pem'), alg='RS256') provider = Provider(signing_key, configuration_information, AuthorizationState(HashBasedSubjectIdentifierFactory(app.config['SUBJECT_ID_HASH_SALT'])), {}, userinfo_ldap) return provider
def inject_provider(request): clients = { TEST_CLIENT_ID: { 'subject_type': 'pairwise', 'redirect_uris': [TEST_REDIRECT_URI], 'response_types': ['code'], 'client_secret': TEST_CLIENT_SECRET, 'token_endpoint_auth_method': 'client_secret_post', 'post_logout_redirect_uris': ['https://client.example.com/post_logout'] } } userinfo = Userinfo({ TEST_USER_ID: { 'name': 'The T. Tester', 'family_name': 'Tester', 'given_name': 'The', 'middle_name': 'Theodore', 'nickname': 'testster', 'email': '*****@*****.**', } }) request.instance.provider = Provider( rsa_key(), {'issuer': ISSUER}, AuthorizationState(HashBasedSubjectIdentifierFactory('salt')), clients, userinfo)
def init_oidc_provider(app): with app.app_context(): issuer = url_for("oidc_provider.index")[:-1] authentication_endpoint = url_for("oidc_provider.authentication_endpoint") jwks_uri = url_for("oidc_provider.jwks_uri") token_endpoint = url_for("oidc_provider.token_endpoint") userinfo_endpoint = url_for("oidc_provider.userinfo_endpoint") registration_endpoint = url_for("oidc_provider.registration_endpoint") end_session_endpoint = url_for("oidc_provider.end_session_endpoint") configuration_information = { "issuer": issuer, "authorization_endpoint": authentication_endpoint, "jwks_uri": jwks_uri, "token_endpoint": token_endpoint, "userinfo_endpoint": userinfo_endpoint, "registration_endpoint": registration_endpoint, "end_session_endpoint": end_session_endpoint, "scopes_supported": ["openid", "profile"], "response_types_supported": [ "code", "code id_token", "code token", "code id_token token", ], # code and hybrid "response_modes_supported": ["query", "fragment"], "grant_types_supported": ["authorization_code", "implicit"], "subject_types_supported": ["pairwise"], "token_endpoint_auth_methods_supported": [ "client_secret_post", "client_secret_basic", ], "claims_parameter_supported": True, } clients = { "sample": { "client_secret": "sample", "redirect_uris": ["http://localhost:5000/test_auth_callback",], "response_types": ["code"], } } userinfo_db = Userinfo(app.users) signing_key = RSAKey(key=rsa_load("signing_key.pem"), alg="RS256") provider = Provider( signing_key, configuration_information, AuthorizationState( HashBasedSubjectIdentifierFactory(app.config["SUBJECT_ID_HASH_SALT"]) ), clients, userinfo_db, ) return provider
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)
def plugin_enable(self) -> None: # pylint: disable=attribute-defined-outside-init self.signing_key = RSAKey(key=rsa_load("signing_key.pem"), use="sig", alg="RS256") # pylint: disable=attribute-defined-outside-init self.oidc_configuration_information = { "issuer": self.app.config["URL_BASE"].replace("http://", "https://"), "authorization_endpoint": self.app.config["URL_BASE"] + "/oidc/authorization", "jwks_uri": self.app.config["URL_BASE"] + "/oidc/jwks", "token_endpoint": self.app.config["URL_BASE"] + "/oidc/token", "userinfo_endpoint": self.app.config["URL_BASE"] + "/oidc/userinfo", "response_types_supported": ["code"], "id_token_signing_alg_values_supported": [self.signing_key.alg], "response_modes_supported": ["fragment", "query"], "subject_types_supported": ["public", "pairwise"], "grant_types_supported": ["authorization_code", "implicit"], "claim_types_supported": ["normal"], "claims_parameter_supported": True, "claims_supported": ["sub", "name", "given_name", "family_name", "email", "profile"], "scopes_supported": ["openid", "email", "profile"] } subject_id_factory = HashBasedSubjectIdentifierFactory( self.app.config["SECRET_KEY"]) # pylint: disable=attribute-defined-outside-init self.provider = Provider( self.signing_key, self.oidc_configuration_information, AuthorizationState( subject_id_factory, SQLWrapper(self.storage, 'authz_codes'), SQLWrapper(self.storage, 'access_tokens'), SQLWrapper(self.storage, 'refresh_tokens'), SQLWrapper(self.storage, 'subject_identifiers', True), ), SQLWrapper(self.storage, 'clients'), Userinfo(PersonWrapper(self.storage)))
def _create_provider( provider_config, endpoint_baseurl, internal_attributes, signing_key, authz_state, user_db, cdb, ): response_types_supported = provider_config.get("response_types_supported", ["id_token"]) subject_types_supported = provider_config.get("subject_types_supported", ["pairwise"]) scopes_supported = provider_config.get("scopes_supported", ["openid"]) extra_scopes = provider_config.get("extra_scopes") capabilities = { "issuer": provider_config["issuer"], "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": [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 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 provider_config.get("client_registration_supported", False): capabilities["registration_endpoint"] = "{}/{}".format( endpoint_baseurl, RegistrationEndpoint.url) provider = Provider( signing_key, capabilities, authz_state, cdb, Userinfo(user_db), extra_scopes=extra_scopes, id_token_lifetime=provider_config.get("id_token_lifetime", 3600), ) return provider
def test_jwks(self): provider = Provider(rsa_key(), provide_configuration(), None, None, None) assert provider.jwks == {'keys': [provider.signing_key.serialize()]}
def test_provider_configuration(self): config = provide_configuration() config.update({'foo': 'bar', 'abc': 'xyz'}) provider = Provider(None, config, None, None, None) provider_config = provider.provider_configuration assert all(k in provider_config for k in config)
class OIDCPlugin(KosekiPlugin): def config(self) -> dict: return {} def plugin_enable(self) -> None: # pylint: disable=attribute-defined-outside-init self.signing_key = RSAKey(key=rsa_load("signing_key.pem"), use="sig", alg="RS256") # pylint: disable=attribute-defined-outside-init self.oidc_configuration_information = { "issuer": self.app.config["URL_BASE"].replace("http://", "https://"), "authorization_endpoint": self.app.config["URL_BASE"] + "/oidc/authorization", "jwks_uri": self.app.config["URL_BASE"] + "/oidc/jwks", "token_endpoint": self.app.config["URL_BASE"] + "/oidc/token", "userinfo_endpoint": self.app.config["URL_BASE"] + "/oidc/userinfo", "response_types_supported": ["code"], "id_token_signing_alg_values_supported": [self.signing_key.alg], "response_modes_supported": ["fragment", "query"], "subject_types_supported": ["public", "pairwise"], "grant_types_supported": ["authorization_code", "implicit"], "claim_types_supported": ["normal"], "claims_parameter_supported": True, "claims_supported": ["sub", "name", "given_name", "family_name", "email", "profile"], "scopes_supported": ["openid", "email", "profile"] } subject_id_factory = HashBasedSubjectIdentifierFactory( self.app.config["SECRET_KEY"]) # pylint: disable=attribute-defined-outside-init self.provider = Provider( self.signing_key, self.oidc_configuration_information, AuthorizationState( subject_id_factory, SQLWrapper(self.storage, 'authz_codes'), SQLWrapper(self.storage, 'access_tokens'), SQLWrapper(self.storage, 'refresh_tokens'), SQLWrapper(self.storage, 'subject_identifiers', True), ), SQLWrapper(self.storage, 'clients'), Userinfo(PersonWrapper(self.storage))) def create_blueprint(self) -> Blueprint: blueprint: Blueprint = Blueprint("oidc", __name__, template_folder="./templates") blueprint.add_url_rule("/.well-known/openid-configuration", None, self.oidc_config, methods=["GET"]) blueprint.add_url_rule("/oidc/jwks", None, self.oidc_jwks, methods=["GET"]) blueprint.add_url_rule("/oidc/authorization", None, self.auth.require_session( self.oidc_authorization, None), methods=["GET"]) blueprint.add_url_rule("/oidc/token", None, self.oidc_token, methods=["GET", "POST"]) blueprint.add_url_rule("/oidc/userinfo", None, self.oidc_userinfo, methods=["GET", "POST"]) return blueprint def oidc_config(self) -> Union[str, Response]: return Response(self.provider.provider_configuration.to_json(), mimetype="application/json") def oidc_jwks(self) -> Union[str, Response]: return jsonify(self.provider.jwks) # require_session def oidc_authorization(self) -> Union[str, Response]: person: Person = self.storage.session.query(Person).filter_by( uid=self.util.current_user()).scalar() if person.state != "active": logging.warning( "User {} tried to log in via OIDC without membership!", person.uid) self.util.alert( KosekiAlert( KosekiAlertType.DANGER, "Missing membership!", "OpenID Connect login was successful, but active " + "membership is required to log in to 3rd party services.", )) return render_template("oidc.html") try: # Override claims to always only and at least return email for CF authn_req = AuthorizationRequest().from_dict({ **request.args, "claims": { "id_token": { "email": { "essential": True } } } }) logging.debug(authn_req) authn_req.verify() if authn_req["client_id"] not in self.provider.clients: self.util.alert( KosekiAlert( KosekiAlertType.DANGER, "Invalid Client", "Unknown Client ID. All applications must be pre-registered.", )) return render_template("oidc.html") client = self.provider.clients[authn_req["client_id"]] if client["redirect_uri"] != authn_req["redirect_uri"]: self.util.alert( KosekiAlert( KosekiAlertType.DANGER, "Incorrect Redirect URI", "Unauthorized redirect URI for this client.", )) return render_template("oidc.html") authn_response = self.provider.authorize( authn_req, str(self.util.current_user())) return_url = authn_response.request( authn_req["redirect_uri"], should_fragment_encode(authn_req)) return redirect(return_url) except Exception as err: # pylint: disable=broad-except logging.error(err) self.util.alert( KosekiAlert( KosekiAlertType.DANGER, "OIDC Error", "Error: {}".format(str(err)), )) return render_template("oidc.html") def oidc_token(self) -> Union[str, Response]: print(request) try: token_response = self.provider.handle_token_request( request.get_data().decode("utf-8"), request.headers) return Response(token_response.to_json(), mimetype="application/json") except InvalidClientAuthentication as err: error_resp = TokenErrorResponse(error="invalid_client", error_description=str(err)) http_response = Response(error_resp.to_json(), status=401, mimetype="application/json") http_response.headers["WWW-Authenticate"] = "Basic" logging.error(error_resp.to_dict()) return http_response except OAuthError as err: error_resp = TokenErrorResponse(error=err.oauth_error, error_description=str(err)) return Response(error_resp.to_json(), status=400, mimetype="application/json") def oidc_userinfo(self) -> Union[str, Response]: try: response = self.provider.handle_userinfo_request( request.get_data().decode("utf-8"), request.headers) return Response(response.to_json(), mimetype="application/json") except (BearerTokenError, InvalidAccessToken) as err: error_resp = UserInfoErrorResponse(error="invalid_token", error_description=str(err)) http_response = Response(error_resp.to_json(), status=401, mimetype="application/json") http_response.headers[ "WWW-Authenticate"] = AccessToken.BEARER_TOKEN_TYPE logging.error(error_resp.to_dict()) return http_response
def test_jwks(self): provider = Provider(rsa_key(), {'issuer': ISSUER}, None, None, None) assert provider.jwks == {'keys': [provider.signing_key.serialize()]}
def test_provider_configuration(self): config = {'issuer': ISSUER, 'foo': 'bar', 'abc': 'xyz'} provider = Provider(None, config, None, None, None) provider_config = provider.provider_configuration assert all(k in provider_config for k in config)
def init_oidc_provider(app): with app.app_context(): config = { 'issuer': app.config['OIDC_PROVIDER']['issuer'], 'authorization_endpoint': url_for('oidc_provider.auth'), 'jwks_uri': url_for('oidc_provider.jwks'), 'token_endpoint': url_for('oidc_provider.token'), 'userinfo_endpoint': url_for('oidc_provider.userinfo'), 'registration_endpoint': url_for('oidc_provider.register'), 'end_session_endpoint': url_for('oidc_provider.logout'), 'scopes_supported': ['openid', 'profile'], 'response_types_supported': ['code', 'code id_token', 'code token', 'code id_token token'], 'response_modes_supported': ['query', 'fragment'], 'grant_types_supported': ['authorization_code', 'implicit'], 'subject_types_supported': ['pairwise'], 'token_endpoint_auth_methods_supported': ['client_secret_basic'], 'claims_parameter_supported': True } userinfo_db = Userinfo() signing_key = RSAKey(key=rsa_load('signing_key.pem'), alg='RS256') authz_state = AuthorizationState( HashBasedSubjectIdentifierFactory( app.config['OIDC_PROVIDER']['subject_id_hash_salt'])) clients = { 'notify-test': { 'client_name': 'GOV.UK Notify', 'client_secret': 'notify-secret', 'client_uri': 'https://admin-ags.notify.works/', 'post_logout_redirect_uris': [ 'https://admin-ags.notify.works/sign-out', 'http://localhost:6012/sign-out', ], 'redirect_uris': [ 'https://admin-ags.notify.works/oidc_callback', 'http://localhost:6012/oidc_callback', ], 'response_types': [ 'code', ], }, 'test-client': { 'client_name': 'Test', 'client_secret': 'test-secret', 'post_logout_redirect_uris': [ 'http://example.com/sign-out', 'https://sue-my-brother.cloudapps.digital/sign-out', ], 'redirect_uris': [ 'http://example.com/oidc_callback', 'https://sue-my-brother.cloudapps.digital/oidc_callback', ], 'response_types': [ 'code', ], }, } provider = Provider(signing_key, config, authz_state, clients, userinfo_db) return provider
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
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
def init_oidc_provider(app): with app.app_context(): issuer = url_for("oidc_provider.index")[:-1] authentication_endpoint = url_for( "oidc_provider.authentication_endpoint") jwks_uri = url_for("oidc_provider.jwks_uri") token_endpoint = url_for("oidc_provider.token_endpoint") userinfo_endpoint = url_for("oidc_provider.userinfo_endpoint") registration_endpoint = url_for("oidc_provider.registration_endpoint") end_session_endpoint = url_for("oidc_provider.end_session_endpoint") configuration_information = { "issuer": issuer, "authorization_endpoint": authentication_endpoint, "jwks_uri": jwks_uri, "token_endpoint": token_endpoint, "userinfo_endpoint": userinfo_endpoint, "registration_endpoint": registration_endpoint, "end_session_endpoint": end_session_endpoint, "scopes_supported": app.config["OIDC_SCOPES_SUPPORTED"], "response_types_supported": app.config["OIDC_RESPONSE_TYPES_SUPPORTED"], "response_modes_supported": app.config["OIDC_RESPONSE_MODES_SUPPORTED"], "grant_types_supported": app.config["OIDC_GRANT_TYPES_SUPPORTED"], "subject_types_supported": app.config["OIDC_SUBJECT_TYPE_SUPPORTED"], "token_endpoint_auth_methods_supported": app.config["OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED"], "userinfo_signing_alg_values_supported": app.config["OIDC_USERINFO_SIGNING_ALG_VALUES_SUPPORTED"], "claims_parameter_supported": app.config["OIDC_CLAIMS_PARAMETER_SUPPORTED"], "claims_supported": app.config["OIDC_CLAIMS_SUPPORTED"], } userinfo_db = Userinfo(app.sql_backend) signing_key = RSAKey(key=rsa_load(app.config["SIGNING_KEY_FILE"]), alg=app.config["SIGNING_KEY_ALG"], kid=app.config["SIGNING_KEY_ID"]) provider = Provider( signing_key, configuration_information, AuthorizationState( HashBasedSubjectIdentifierFactory( app.config["SUBJECT_ID_HASH_SALT"])), ClientRPSQLWrapper(), userinfo_db, ) return provider