From 1e1318ca19982fe3a7ec953079b18bf4aeb5b8e2 Mon Sep 17 00:00:00 2001 From: Christian Legaspi <1392774+clegaspi@users.noreply.github.com> Date: Wed, 2 Apr 2025 15:58:49 -0700 Subject: [PATCH 1/4] Fix dash update --- saml_reader/web/run.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/saml_reader/web/run.py b/saml_reader/web/run.py index a6925f8..8919805 100644 --- a/saml_reader/web/run.py +++ b/saml_reader/web/run.py @@ -35,7 +35,12 @@ def run_web_app(host="localhost", port=8050, server_timeout=None, **options): shutting it down as a security measure. **options (optional): Keyword arguments sent to underlying Dash/Flask launcher. """ - server_func = partial(app.run_server, host=host, port=port, **options) + if hasattr(app, "run"): + # Modern dash + server_func = partial(app.run, host=host, port=port, **options) + else: + # Legacy dash + server_func = partial(app.run_server, host=host, port=port, **options) if server_timeout is not None: # Avoiding spawning extra thread in case bad clash with gunicorn thread = Thread(target=server_func) From 85f9d5860ca3800c8364a55d2abdea678c49ff9e Mon Sep 17 00:00:00 2001 From: Christian Legaspi <1392774+clegaspi@users.noreply.github.com> Date: Wed, 2 Apr 2025 16:01:24 -0700 Subject: [PATCH 2/4] Default to localhost instead of 0/0 --- saml_reader/web/run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/saml_reader/web/run.py b/saml_reader/web/run.py index 8919805..93f89c0 100644 --- a/saml_reader/web/run.py +++ b/saml_reader/web/run.py @@ -57,14 +57,14 @@ def run_web_app(host="localhost", port=8050, server_timeout=None, **options): if __name__ == "__main__": # Defaults - host = "0.0.0.0" + host = "localhost" use_flask_debug_mode = True # Checking command line arguments # TODO: Maybe replace this with argparse if len(sys.argv) > 1: - if "--local" in sys.argv: - host = "localhost" + if "--deploy" in sys.argv: + host = "0.0.0.0" if "--using-debugger" in sys.argv: use_flask_debug_mode = False From 888000c617b98d0147c7ad1bf0b4aa8e7c60bb27 Mon Sep 17 00:00:00 2001 From: Christian Legaspi <1392774+clegaspi@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:18:58 -0700 Subject: [PATCH 3/4] Remove whitespace --- saml_reader/saml/parser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/saml_reader/saml/parser.py b/saml_reader/saml/parser.py index 4445742..8a861d1 100644 --- a/saml_reader/saml/parser.py +++ b/saml_reader/saml/parser.py @@ -230,6 +230,7 @@ def from_base64(cls, base64, url_decode=False): (BaseSamlParser) parsed SAML response object """ value = base64 if not url_decode else unquote(base64) + value = re.sub(r"\s+", "", value) # Remove whitespace # Check to see if this is valid base64 rx = r"[^a-zA-Z0-9/+=]" if re.search(rx, value): From 3180ab44ece6497b368a41132f94aeebb023d615 Mon Sep 17 00:00:00 2001 From: Christian Legaspi <1392774+clegaspi@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:19:26 -0700 Subject: [PATCH 4/4] Store token in session cache server side --- saml_reader/web/callbacks/analyze.py | 87 ++++++++++++++++++++++------ saml_reader/web/callbacks/crypto.py | 11 ++++ 2 files changed, 79 insertions(+), 19 deletions(-) diff --git a/saml_reader/web/callbacks/analyze.py b/saml_reader/web/callbacks/analyze.py index ecf06ea..e5dec17 100644 --- a/saml_reader/web/callbacks/analyze.py +++ b/saml_reader/web/callbacks/analyze.py @@ -23,11 +23,14 @@ from saml_reader.web.callbacks.crypto import ( encrypt_string, decrypt_string, + generate_session_id, CRYPTO_STATE, ) USER_AGENT = f"saml-reader/{__version__}" +_SESSION_CACHE: dict[str, str] = {} + def submit_analysis_to_backend(data_type, saml_data, comparison_data): """Sends data to the SAML reader backend after compilation from the @@ -363,7 +366,12 @@ def validate_url_and_authenticate_sdk( url_value, ) if not rx or not rx.group("id"): - return "Invalid URL", {"color": "red"}, False + return ( + html.P("Invalid URL", style={"color": "red"}), + False, + {"color": "red"}, + False, + ) federation_id = rx.group("id") @@ -468,7 +476,8 @@ def check_sdk_authentication(n_intervals: int | None) -> tuple[Any, bool, Any, b if token is None: raise PreventUpdate - write_token_to_cookie(token) + session_id = write_session_id_cookie() + write_token_to_cache(session_id, token) federation_id = get_cookie("saml-reader-federation-id", decrypt=False) return ( html.P(f"Looking up federation {federation_id}", style={"color": "black"}), @@ -500,6 +509,14 @@ def do_idp_lookup(children: Any) -> tuple[Any, bool]: raise PreventUpdate client = get_atlas_client() + if not client: + return ( + html.P( + "There was an error connecting to Atlas.", + style={"color": "red"}, + ), + False, + ) federation_id = get_cookie("saml-reader-federation-id", decrypt=False) @@ -519,7 +536,7 @@ def do_idp_lookup(children: Any) -> tuple[Any, bool]: idps = [ { "label": html.Span( - [f'{x["displayName"]} ({x["status"]}, ID: {x["id"]})'], + [f"{x['displayName']} ({x['status']}, ID: {x['id']})"], style={"font-size": 14}, ), "value": json.dumps(x), @@ -618,7 +635,10 @@ def get_atlas_client() -> PublicV2ApiClient | None: PublicV2ApiClient | None: If token is valid, returns the client, otherwise returns None to indicate the user needs to reauthenticate. """ - token = read_token_from_cookie() + session_id = read_session_id_cookie() + if not session_id: + return None + token = read_token_from_cache(session_id) if not token: return None @@ -632,7 +652,7 @@ def get_atlas_client() -> PublicV2ApiClient | None: if not client.test_auth(): return None - write_token_to_cookie(client.profile.token) + write_token_to_cache(session_id, client.profile.token) return client @@ -668,7 +688,7 @@ def set_cookie(name: str, value: str, /, secure: bool = False, **kwargs): name (str): name of the cookie value (str): value of the cookie secure (bool, optional): whether the cookie will be marked as secure. - If True, the value will be encrypted. Defaults to True. + If True, the value will be encrypted. Defaults to False. **kwargs: other configuration values for the cookie """ if secure: @@ -676,33 +696,62 @@ def set_cookie(name: str, value: str, /, secure: bool = False, **kwargs): ctx.response.set_cookie(name, value, secure=secure, **kwargs) -def write_token_to_cookie(token: Token): - """Write the OAuth token to a cookie in the response. +def write_session_id_cookie(session_id: str | None = None) -> str: + """Generate a session ID cookie and optionally cache the session ID. Args: - token (Token): the token object + session_id (str | None, optional): the session ID to be set in the cookie. + If None, a new session ID will be generated. Defaults to None. + + Returns: + str: the session ID that was set in the cookie. """ - token_dict = asdict(token) - token_dict["issue_time"] = token_dict["issue_time"].timestamp() + + if session_id is None: + session_id = generate_session_id() set_cookie( - "saml-reader-atlas-token", - json.dumps(token_dict), + "saml-reader-atlas-session", + session_id, secure=True, httponly=True, ) + return session_id -def read_token_from_cookie() -> Token | None: - """Read the OAuth token from a cookie in the request. +def read_session_id_cookie() -> str | None: + """Read the session ID from a cookie in the request. Returns: - Token: the token object, if present and decryptable, - otherwise None + str | None: the session ID, if present, otherwise None + """ + return get_cookie("saml-reader-atlas-session") + + +def write_token_to_cache(session_id: str, token: Token): + """Write the OAuth token to the session cache for session ID. + + Args: + session_id (str): the session ID for which the token is being cached. + token (Token): the token object + """ + token_dict = asdict(token) + token_dict["issue_time"] = token_dict["issue_time"].timestamp() + _SESSION_CACHE[session_id] = encrypt_string(json.dumps(token_dict)) + + +def read_token_from_cache(session_id: str) -> Token | None: + """Read the OAuth token from the session cache for the given session ID. + + Args: + session_id (str): the session ID for which the token is being retrieved. + + Returns: + Token: the token object, if present, otherwise None """ - token_json = get_cookie("saml-reader-atlas-token") + token_json = _SESSION_CACHE.get(session_id, None) if not token_json: return None - token_dict = json.loads(token_json) + token_dict = json.loads(decrypt_string(token_json)) token_dict["issue_time"] = datetime.fromtimestamp(token_dict["issue_time"]) return Token(**token_dict) diff --git a/saml_reader/web/callbacks/crypto.py b/saml_reader/web/callbacks/crypto.py index 68ac38b..434020c 100644 --- a/saml_reader/web/callbacks/crypto.py +++ b/saml_reader/web/callbacks/crypto.py @@ -1,3 +1,5 @@ +import secrets + from cryptography.fernet import Fernet # Encryption object unique to this app session @@ -32,3 +34,12 @@ def decrypt_string(data: str) -> str: if not data: return "" return str(_crypto.decrypt(bytes(data, "UTF-8")), "UTF-8") + + +def generate_session_id() -> str: + """Generate a unique session ID. + + Returns: + str: unique session ID + """ + return secrets.token_urlsafe(32)