From f5cb1df28b71cb78a1ac69ab5db39a923acc6925 Mon Sep 17 00:00:00 2001 From: peterbolha <xbolha@fi.muni.cz> Date: Thu, 15 Feb 2024 09:51:20 +0100 Subject: [PATCH] feat: satosa oidc probe --- README.md | 29 ++ perun/proxy/utils/nagios/check_oidc_login.py | 341 +++++++++++++++++++ setup.py | 3 + 3 files changed, 373 insertions(+) create mode 100644 perun/proxy/utils/nagios/check_oidc_login.py diff --git a/README.md b/README.md index b1f48be..2a8a2d9 100644 --- a/README.md +++ b/README.md @@ -221,3 +221,32 @@ For usage instructions, run: ```sh check_pgsql --help ``` + +### check_oidc_login + +Check that OIDC auth process works by acting as an OIDC client (RP) attempting to +authenticate against an OIDC server (OP). Supports clients with PKCE and non-PKCE +authentication. Additional tag `-pkce` must be added to test PKCE log in. Using client +secret is always enforced since some clients might require it on top of PKCE. + +Target OIDC server (OP) should **not** require authentication for this probe to +work. It **does not** test the credentials' validity but rather the OIDC auth process. This +can be achieved, for example, by setting up SATOSA's reflector backend. + +For usage instructions, run: + +```sh +check_oidc_login --help +``` + +Example: + +```sh +python3 check_oidc_login + --redirect-uri "http://localhost:44322/signin-oidc" + --client-id "my_oidc_client_id" + --client-secret "my_oidc_pkce_secret" + --issuer "https://id.muni.cz" + -vvv # verbose debug output + -pkce +``` diff --git a/perun/proxy/utils/nagios/check_oidc_login.py b/perun/proxy/utils/nagios/check_oidc_login.py new file mode 100644 index 0000000..6752444 --- /dev/null +++ b/perun/proxy/utils/nagios/check_oidc_login.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 + +""" +make a full roundtrip test.yaml for SAML based SSO +""" + +import argparse +import http.cookiejar +import http.server +import json +import logging +import os +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from enum import Enum +from http.client import HTTPResponse +from typing import Dict, Any, List +from urllib.parse import urlparse + +from flask import Flask, Response, request +from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient + +CALLBACK_ENDPOINT_CALLED = threading.Event() + + +class ClientConfigData: + def __init__( + self, client_id: str, client_secret: str, issuer: str, scopes: List[str] + ): + self.client_id = client_id + self.client_secret = client_secret + self.issuer = issuer + self.scopes = scopes + + +class Status(Enum): + OK = 0, "OK" + WARNING = 1, "WARNING" + CRITICAL = 2, "CRITICAL" + UNKNOWN = 3, "UNKNOWN" + + +class Evaluator: + def __init__(self): + self.start_time = None + + def start_test(self): + self.start_time = time.time() + + def finish_test(self, message: str, status: Status = Status.OK): + if not self.start_time: + raise ValueError("Test has not been started.") + + auth_time = None + if status == Status.OK: + auth_time = round(time.time() - self.start_time, 2) + + message = f"{message}|authtime={auth_time or ''};" + status_code, status_name = status.value + + print(f"{status_name} - {message}") + os._exit(status_code) + + +def silence_flask_logs(app: Flask) -> None: + log = logging.getLogger("werkzeug") + log.setLevel(logging.ERROR) + app.logger.disabled = True + log.disabled = True + + +def get_flask_app( + client: StandAloneClient, evaluator: Evaluator, callback_path: str, verbose: int +) -> Flask: + app = Flask(__name__) + app.config["client"] = client + app.config["evaluator"] = evaluator + silence_flask_logs(app) + + @app.route(callback_path) + def signin_oidc(): + CALLBACK_ENDPOINT_CALLED.set() + client: StandAloneClient = app.config["client"] + query_params = request.args.to_dict() + + if verbose >= 3: + print(f"Parameters returned to callback after OIDC auth '{query_params}'") + + auth_response = client.finalize(query_params) + + if verbose >= 3: + print( + f"Final authentication response after exchange of code for token '" + f"{auth_response}'" + ) + + response = Response(status=200, headers={"Content-type": "text/plain"}) + + @response.call_on_close + def trigger_evaluation(): + # trigger auth response evaluation after the flask response has been sent + if not auth_response.get("id_token"): + evaluator.finish_test( + "ID token was not found in OP's response.", Status.CRITICAL + ) + + if not auth_response.get("userinfo"): + evaluator.finish_test( + "User info was not found in OP's response.", Status.CRITICAL + ) + + evaluator.finish_test("Authentication was successful.", Status.OK) + + return response + + return app + + +def start_flask_app( + client: StandAloneClient, evaluator: Evaluator, redirect_uri: str, verbose: int +): + parsed_redirect_uri = urlparse(redirect_uri) + app = get_flask_app(client, evaluator, parsed_redirect_uri.path, verbose) + + app.run(host=parsed_redirect_uri.hostname, port=parsed_redirect_uri.port) + + +def get_args(): + """ + Supports the command-line arguments listed below. + """ + parser = argparse.ArgumentParser(description="SAML authentication check") + parser._optionals.title = "Options" + parser.add_argument( + "--redirect-uri", + "-r", + required=True, + help="URI where OIDC callback will be redirected", + ) + parser.add_argument( + "--client-id", + "-cid", + required=True, + help="ID of OIDC client registered with OP", + ) + parser.add_argument( + "--client-secret", + "-cs", + required=True, + help="secret of OIDC client registered with OP", + ) + parser.add_argument( + "--issuer", + "-i", + required=True, + help="Issuer used for OIDC auth", + ) + parser.add_argument( + "--scopes", + "-s", + nargs="+", + help="List of scopes client will ask to access", + default=["openid", "email", "profile"], + ) + parser.add_argument( + "--verbose", + "-v", + action="count", + default=0, + help="verbose mode (for debugging)", + ) + parser.add_argument( + "--remember-me", + action="store_true", + help="check the Remember me option when logging in", + ) + parser.add_argument( + "--use-pkce", + "-pkce", + action="store_true", + help="use pkce method for logging in", + ) + + return parser.parse_args() + + +class OIDCChecker: + def curl(self, url, data=None): + if self.args.verbose >= 1: + print("curl: {}".format(url)) + req = urllib.request.Request( + url=url, + data=urllib.parse.urlencode(data).encode("ascii") if data else None, + ) + response = None + try: + response: HTTPResponse = self.opener.open(req) + return response + except urllib.error.URLError as e: + if self.args.verbose >= 1: + print(e) + if self.args.verbose >= 2: + print(response) + self.evaluator.finish_test(e.reason, Status.CRITICAL) + + def perform_oidc_auth(self, url: str) -> None: + # Reflector backend ensures that no additional information needs to be sent + # to the oidc auth endpoint and the auth should pass successfully + self.curl(url) + + def main(self): + """ + CMD Line tool + """ + + self.evaluator.start_test() + + # 1. start the Flask endpoint which handles OIDC callback + if self.args.verbose >= 3: + print("STEP 1 - starting flask endpoint (callback)") + + thread = threading.Thread( + target=start_flask_app, + args=[ + self.client, + self.evaluator, + self.args.redirect_uri, + self.args.verbose, + ], + ) + thread.start() + + # 2. initiate auth process + if self.args.verbose >= 3: + print("STEP 2 - initiating OIDC auth process") + + req_args = {"redirect_uri": self.args.redirect_uri} + login_url = self.client.init_authorization(req_args=req_args) + if self.args.verbose >= 3: + print(f"OIDC initiating url: '{login_url}'") + + # 3. further proceed with auth + if self.args.verbose >= 3: + print("STEP 3 - proceeding with second OIDC auth step") + + self.perform_oidc_auth(login_url) + + # This point should not be reached if callback endpoint was called + if not CALLBACK_ENDPOINT_CALLED.is_set(): + self.evaluator.finish_test( + "Callback endpoint was not called.", Status.CRITICAL + ) + + def get_issuer_info(self, issuer): + well_known_endpoint = f"{issuer}/.well-known/openid-configuration" + urllib.request.Request(well_known_endpoint) + response = self.curl(well_known_endpoint) + return json.loads(response.read().decode("utf-8")) + + def get_non_pkce_client_cfg( + self, client_config_data: ClientConfigData + ) -> Dict[str, Any]: + return { + "provider_info": { + "issuer": client_config_data.issuer, + }, + "client_id": client_config_data.client_id, + "client_secret": client_config_data.client_secret, + "scopes_supported": client_config_data.scopes, + "client_type": "oidc", + } + + def get_pkce_client_cfg( + self, client_config_data: ClientConfigData + ) -> Dict[str, Any]: + base_config = self.get_non_pkce_client_cfg(client_config_data) + issuer_info = self.get_issuer_info(client_config_data.issuer) + + additional_provider_info = { + "jwks_uri": issuer_info.get("jwks_uri"), + "userinfo_endpoint": issuer_info.get("userinfo_endpoint"), + "token_endpoint": issuer_info.get("token_endpoint"), + "authorization_endpoint": issuer_info.get("authorization_endpoint"), + } + base_config["provider_info"].update(additional_provider_info) + + pkce_addon_info = { + "add_ons": { + "pkce": { + "function": "idpyoidc.client.oauth2.add_on.pkce.add_support", + "kwargs": { + "code_challenge_length": 64, + "code_challenge_method": "S256", + }, + }, + }, + } + base_config.update(pkce_addon_info) + + return base_config + + def get_registered_client( + self, client_config_data: ClientConfigData + ) -> StandAloneClient: + if self.is_using_pkce: + client_cfg = self.get_pkce_client_cfg(client_config_data) + else: + client_cfg = self.get_non_pkce_client_cfg(client_config_data) + + client = StandAloneClient(config=client_cfg) + client.do_provider_info() # get provider info based on issuer in client's + # config + client.do_client_registration() # client is configured statically if + # client_id is provided in the config + + return client + + def __init__(self, args): + self.args = args + self.cookiejar = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.cookiejar), + ) + self.is_using_pkce = args.use_pkce + client_config_data = ClientConfigData( + args.client_id, args.client_secret, args.issuer, args.scopes + ) + self.client = self.get_registered_client(client_config_data) + self.evaluator = Evaluator() + + +def main(): + checker = OIDCChecker(get_args()) + checker.main() + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 89ac5fa..ebefb7c 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,8 @@ setuptools.setup( "pyotp~=2.9", "perun.connector~=3.8", "privacyidea~=3.9", + "flask~=1.1", + "idpyoidc~=2.1", ], extras_require={ "ldap": [ @@ -40,6 +42,7 @@ setuptools.setup( "check_ldap_syncrepl=check_syncrepl_extended.check_syncrepl_extended:main", "check_mongodb=perun.proxy.utils.nagios.check_mongodb:main", "check_nginx=check_nginx_status.check_nginx_status:main", + "check_oidc_login=perun.proxy.utils.nagios.check_oidc_login:main", "check_rpc_status=perun.proxy.utils.nagios.check_rpc_status:main", "check_saml=perun.proxy.utils.nagios.check_saml:main", "check_user_logins=perun.proxy.utils.nagios.check_user_logins:main", -- GitLab