diff --git a/bin/run_pyl b/bin/run_pyl index 93329c26f..e81f4f484 100755 --- a/bin/run_pyl +++ b/bin/run_pyl @@ -1,7 +1,7 @@ #!/usr/bin/env bash function error_handler() { - >&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." + echo >&2 "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." exit "$2" } trap 'error_handler ${LINENO} $?' ERR @@ -31,23 +31,16 @@ function get_python_dirs() { } function run_autofixers() { - # checking command -v ruff is not good enough, since the asdf shim may be installed, which will make command -v succeed, - # but ruff may not have been pip installed inside the correct version of python. - if ! ruff --help >/dev/null 2>&1; then - pip install ruff - asdf reshim python - fi - python_dirs="$(get_python_dirs) bin" # shellcheck disable=2086 - ruff --fix $python_dirs || echo '' + poetry run ruff --fix $python_dirs || echo '' } function run_pre_commmit() { poetry run pre-commit run --verbose --all-files } -for react_project in "${react_projects[@]}" ; do +for react_project in "${react_projects[@]}"; do # if pre, only do stuff when there are changes if [[ -n "$(git status --porcelain "$react_project")" ]]; then pushd "$react_project" @@ -56,7 +49,7 @@ for react_project in "${react_projects[@]}" ; do fi done -for python_project in "${python_projects[@]}" ; do +for python_project in "${python_projects[@]}"; do # if pre, only do stuff when there are changes if [[ "$subcommand" != "pre" ]] || [[ -n "$(git status --porcelain "$python_project")" ]]; then pushd "$python_project" @@ -73,7 +66,7 @@ fi function clear_log_file() { unit_testing_log_file="./log/unit_testing.log" if [[ -f "$unit_testing_log_file" ]]; then - > "$unit_testing_log_file" + >"$unit_testing_log_file" fi } diff --git a/spiffworkflow-backend/bin/local_development_environment_setup b/spiffworkflow-backend/bin/local_development_environment_setup index 78c60fe44..32a8f0572 100755 --- a/spiffworkflow-backend/bin/local_development_environment_setup +++ b/spiffworkflow-backend/bin/local_development_environment_setup @@ -1,7 +1,7 @@ #!/usr/bin/env bash function error_handler() { - >&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." + echo >&2 "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." exit "$2" } trap 'error_handler ${LINENO} $?' ERR @@ -19,7 +19,6 @@ if [[ -d "$process_model_dir" ]]; then shift fi - if [[ -z "${SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR:-}" ]]; then if [[ -n "${process_model_dir}" ]] && [[ -d "${process_model_dir}" ]]; then SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR="$process_model_dir" @@ -33,8 +32,8 @@ if [[ "$process_model_dir" == "acceptance" ]]; then export SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=true export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=acceptance_tests.yml elif [[ "$process_model_dir" == "localopenid" ]]; then - export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="openid" - export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="openid" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="default" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="inernal openid" export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__uri="http://localhost:$port/openid" export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_id="spiffworkflow-backend" export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q" diff --git a/spiffworkflow-backend/bin/run_server_locally b/spiffworkflow-backend/bin/run_server_locally index 970cd6f64..8a7d66c6e 100755 --- a/spiffworkflow-backend/bin/run_server_locally +++ b/spiffworkflow-backend/bin/run_server_locally @@ -1,13 +1,16 @@ #!/usr/bin/env bash function error_handler() { - >&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." + echo >&2 "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." exit "$2" } trap 'error_handler ${LINENO} $?' ERR set -o errtrace -o errexit -o nounset -o pipefail -script_dir="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +script_dir="$( + cd -- "$(dirname "$0")" >/dev/null 2>&1 + pwd -P +)" . "${script_dir}/local_development_environment_setup" server_type="${1:-api}" @@ -24,7 +27,7 @@ else SPIFFWORKFLOW_BACKEND_RUN_BACKGROUND_SCHEDULER_IN_CREATE_APP=false SPIFFWORKFLOW_BACKEND_FAIL_ON_INVALID_PROCESS_MODELS=false poetry run python bin/save_all_bpmn.py fi - # this line blocks - poetry run flask run -p "$port" --host=0.0.0.0 + # this line blocks + poetry run flask run -p "$port" --host=0.0.0.0 fi fi diff --git a/spiffworkflow-backend/bin/tests-par b/spiffworkflow-backend/bin/tests-par index 05b34e01c..52f3200b6 100755 --- a/spiffworkflow-backend/bin/tests-par +++ b/spiffworkflow-backend/bin/tests-par @@ -1,20 +1,20 @@ #!/usr/bin/env bash function error_handler() { - >&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." + echo >&2 "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." exit "$2" } trap 'error_handler ${LINENO} $?' ERR set -o errtrace -o errexit -o nounset -o pipefail -if [[ ! -f ./src/instance/db_unit_testing_gw0.sqlite3 ]] ; then - >&2 echo -e "ERROR: please run the following command first in order to set up and migrate the sqlite unit_testing database:\n\n\tSPIFFWORKFLOW_BACKEND_DATABASE_TYPE=sqlite ./bin/recreate_db clean" +if [[ ! -f ./src/instance/db_unit_testing_gw0.sqlite3 ]]; then + echo >&2 -e "ERROR: please run the following command first in order to set up and migrate the sqlite unit_testing database:\n\n\tSPIFFWORKFLOW_BACKEND_DATABASE_TYPE=sqlite ./bin/recreate_db clean" exit 1 fi # check if python package pytest-xdist is installed -if ! python -c "import xdist" &>/dev/null; then - >&2 echo -e "ERROR: please install the python package pytest-xdist by running poetry install" +if ! poetry run python -c "import xdist" &>/dev/null; then + echo >&2 -e "ERROR: please install the python package pytest-xdist by running poetry install" exit 1 fi diff --git a/spiffworkflow-backend/conftest.py b/spiffworkflow-backend/conftest.py index db501c158..2b96c30c3 100644 --- a/spiffworkflow-backend/conftest.py +++ b/spiffworkflow-backend/conftest.py @@ -23,12 +23,16 @@ if os.environ.get("RUN_TYPEGUARD") == "true": from spiffworkflow_backend import create_app # noqa: E402 -@pytest.fixture(scope="session") -def app() -> Flask: # noqa +def _set_unit_testing_env_variables() -> None: os.environ["SPIFFWORKFLOW_BACKEND_ENV"] = "unit_testing" os.environ["FLASK_SESSION_SECRET_KEY"] = ( "e7711a3ba96c46c68e084a86952de16f" # noqa: S105, do not care about security when running unit tests ) + + +@pytest.fixture(scope="session") +def app() -> Flask: # noqa + _set_unit_testing_env_variables() app = create_app() # to screw with this, poet add nplusone --group dev diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py index 32ea504b4..dc54ce44f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py @@ -19,10 +19,9 @@ from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import migrate from spiffworkflow_backend.routes.authentication_controller import _set_new_access_token_in_cookie -from spiffworkflow_backend.routes.authentication_controller import verify_token +from spiffworkflow_backend.routes.authentication_controller import omni_auth from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import openid_blueprint from spiffworkflow_backend.routes.user_blueprint import user_blueprint -from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.monitoring_service import configure_sentry from spiffworkflow_backend.services.monitoring_service import setup_prometheus_metrics @@ -72,7 +71,11 @@ def create_app() -> flask.app.Flask: app.register_blueprint(user_blueprint) app.register_blueprint(api_error_blueprint) - app.register_blueprint(openid_blueprint, url_prefix="/openid") + + # only register the backend openid server if the backend is configured to use it + backend_auths = app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"] + if len(backend_auths) == 1 and backend_auths[0]["uri"] == f"{app.config['SPIFFWORKFLOW_BACKEND_URL']}/openid": + app.register_blueprint(openid_blueprint, url_prefix="/openid") # preflight options requests will be allowed if they meet the requirements of the url regex. # we will add an Access-Control-Max-Age header to the response to tell the browser it doesn't @@ -89,8 +92,7 @@ def create_app() -> flask.app.Flask: configure_sentry(app) - app.before_request(verify_token) - app.before_request(AuthorizationService.check_for_permission) + app.before_request(omni_auth) app.after_request(_set_new_access_token_in_cookie) # The default is true, but we want to preserve the order of keys in the json diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index e9f50cad8..aa6a1dac4 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -714,8 +714,7 @@ paths: minItems: 1 get: operationId: spiffworkflow_backend.routes.process_api_blueprint.process_caller_list - summary: - Return a list of information about all processes that call the provided process id + summary: Return a list of information about all processes that call the provided process id tags: - Process Models responses: @@ -728,7 +727,6 @@ paths: items: $ref: "#/components/schemas/Process" - /processes: get: operationId: spiffworkflow_backend.routes.process_api_blueprint.process_list @@ -2222,15 +2220,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Task" + $ref: "#/components/schemas/Task" responses: "200": description: Returns the same data structure provided, but after some replacements. content: application/json: schema: - $ref: "#/components/schemas/Task" - + $ref: "#/components/schemas/Task" /tasks/{process_instance_id}/instruction: parameters: @@ -2827,16 +2824,8 @@ paths: "200": description: A list of the data stored in the requested data store. - components: securitySchemes: - jwt: - type: http - scheme: bearer - bearerFormat: JWT - x-bearerInfoFunc: spiffworkflow_backend.routes.authentication_controller.verify_token - x-scopeValidateFunc: spiffworkflow_backend.routes.authentication_controller.validate_scope - oAuth2AuthCode: type: oauth2 description: authenticate with openid server diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py index d300acd23..8cea5b2c0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py @@ -181,6 +181,7 @@ def setup_config(app: Flask) -> None: env_config_prefix = "spiffworkflow_backend.config." env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"] + load_config_file(app, env_config_module) # This allows config/testing.py or instance/config.py to override the default config @@ -226,7 +227,7 @@ def setup_config(app: Flask) -> None: app.config["SPIFFWORKFLOW_BACKEND_MAX_INSTANCE_LOCK_DURATION_IN_SECONDS"] ) - if "SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS" not in app.config: + if app.config.get("SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS") is None: app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"] = [ { "identifier": "default", diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index 91deaacaf..4716aacfd 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -127,6 +127,7 @@ else: } ] + ### logs # loggers to use is a comma separated list of logger prefixes that we will be converted to list of strings config_from_env("SPIFFWORKFLOW_BACKEND_LOGGERS_TO_USE") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/openid/rsa_keys.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/openid/rsa_keys.py new file mode 100644 index 000000000..71c222cd2 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/openid/rsa_keys.py @@ -0,0 +1,41 @@ +# These configs are specifically for the open id server run from backend. +# This should only be used for development and demonstration. SHOULD NOT BE USED IN PROD. +class OpenIdConfigsForDevOnly: + private_key = """-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCB34SwG3IFKAzf +BGm2VCWrqhlhjxXgQjx42Wsp4Nh8UqhSCnBE+/3sMwSxgwmuwDANiV9pkifWDbAy +jLcc5i9YrrwqagyeMk1xjwVmCSB5RPgwJMP3NOyiciM8V5z8qQXE8+Xv9jOoDExC +DL5M/vDsGg4Xe02kdJleUuxmQI2r9PlLklY9p7NUvcFbh1qVEuMQ7ToYqMyuiNop +1cIhRdKzV7BcMhE8Bby+bmmhfRDNhq4zM2W4OHithsdREoeurzTebhUR/RwA9j4a +w6Niy/awBOomgMuqZ+uQXK5R9B2P21jSt+ljMTxcUzoYhJ/yIv/NvgQAvqs0Umqx +4gT7erRPAgMBAAECggEACGj4Hf7w/I3lSeEILW/rVPks/C9rv1V0UAkYRbwTMl3L +gfokeG5VbG25vWFnoTYKoCqUqXDDydQIfgWBuBcQQTuZ4MJ0TB9xLcwREYeNLEn/ +xMvxfexV8VQ90HyfisPK2IziVEGXtidxt4pwULeAbdgA484uyKcD5Xx6/xYc3OIA +T+bfwfydJB5e09e6hZ8glTD50LX7b6Hy5b/iYsYCVzfXh8+DM1UQQQEWA++ZsYo0 +qUBJMoB7L5AbMZaWWmTJilEi4MaBnjPw8JEoW9VH7ZjZiVCPlAV9DxSSeBuwfqUy +KZpbYcJEZNELiTgVcyaqjR3mt1Q4iCYuIkMUJ9/ZdQKBgQC1PPBQYeOcgAzx9o5N +gNYy/19xqL9lhoFle8Y9a28JbX2XpLtTzwj6WbvW4hb2AlBLLc/BAtr2TRn4b7qU +0MhAclvvmh6Zccwfp1tCNn3t6JpdTqIbG8eYgHDnBGK27xvhsyX22rmBcBpJWfMK +T7MpB9y7/r0iU84wAYvOcAS1awKBgQC3cloRPloKqXUQh3k8uWnZucm6/gGPHNir +FyFfHT45rlewlfuLPcN13vezb0vmKRjzHVDqo+1xrUmjFj857uLpQZ4lz7X5Xl9k +hSw0+1heqRmKumQIF12rg5elBfTSvys2N5CiH4EykO3P97jz0zTFij+orYdtbrA8 +LqfyuMARrQKBgQCAlvse3UVkTcphhwESZl4UEvMCLquV+igm+/n8rBQ9SS06AcxT +u2pwTmijHwkRhTS5Eoj8Ne1roerSRVvJqJTcfQdT6jLZxk8BCnoKcaVJvqZ/m4IS +39PvFPqGPqtXhjFvIu/FxQynlQVhk+uIHmJMs2JfFG/XQkTh9MbgMsR0fwKBgQCS +67rG5LEoqN9hBZ9LyxPDlNDEOnr1K508KaJIkxsrBz6j5vs3YZgR5ylrRE/9Xhzl +WS1dPz0ENk0rmL26oGCLgEow7lJIDhVIZIArTsJPzg7u1KkY8d3LZ/Ej8clKoGDz +Yz0rGyBWZ0yPq08tuJIjQ74IUjKMqoHrMVLBSsZJYQJ/U6LpkmH6JrJfC81ql95y +iOtxcJaAH/38HVvhXsCn4/uQUu/CyxH6BLleh7ImdnmO5BMInZwkFHd9tS1AdjiA +M8V/du4022V11ivFv8mwyKbBjsODcwhfOarCm1phOi95ksFA8KJdQPYsEw/Gba0K +CQKKq+mlG5zUY1rU1uXxjA== +-----END PRIVATE KEY-----""" + + public_key = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgd+EsBtyBSgM3wRptlQl +q6oZYY8V4EI8eNlrKeDYfFKoUgpwRPv97DMEsYMJrsAwDYlfaZIn1g2wMoy3HOYv +WK68KmoMnjJNcY8FZgkgeUT4MCTD9zTsonIjPFec/KkFxPPl7/YzqAxMQgy+TP7w +7BoOF3tNpHSZXlLsZkCNq/T5S5JWPaezVL3BW4dalRLjEO06GKjMrojaKdXCIUXS +s1ewXDIRPAW8vm5poX0QzYauMzNluDh4rYbHURKHrq803m4VEf0cAPY+GsOjYsv2 +sATqJoDLqmfrkFyuUfQdj9tY0rfpYzE8XFM6GISf8iL/zb4EAL6rNFJqseIE+3q0 +TwIDAQAB +-----END PUBLIC KEY-----""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/unit_testing.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/unit_testing.py index ea192bbe0..166632e80 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/unit_testing.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/unit_testing.py @@ -9,6 +9,11 @@ SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME = environ.get( "SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME", default="unit_testing.yml" ) +SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL = "http://localhost:7000/openid" +SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID = "spiffworkflow-backend" +SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY = "JXeQExm0JhQPLumgHtIIqf52bDalHz0q" # noqa: S105 +SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS = None + SPIFFWORKFLOW_BACKEND_LOG_LEVEL = environ.get("SPIFFWORKFLOW_BACKEND_LOG_LEVEL", default="debug") SPIFFWORKFLOW_BACKEND_GIT_COMMIT_ON_SAVE = False diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/exceptions/api_error.py b/spiffworkflow-backend/src/spiffworkflow_backend/exceptions/api_error.py index 2c959a707..a339f9a2c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/exceptions/api_error.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/exceptions/api_error.py @@ -323,5 +323,4 @@ def handle_exception(exception: Exception) -> flask.wrappers.Response: if api_exception.response_headers is not None: for header, value in api_exception.response_headers.items(): error_response.headers[header] = value - return error_response diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py index f8590f455..a3b3217da 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math +import time from dataclasses import dataclass from typing import Any @@ -13,6 +15,9 @@ from spiffworkflow_backend.models.group import GroupModel SPIFF_NO_AUTH_USER = "spiff_no_auth_guest_user" SPIFF_GUEST_USER = "spiff_guest_user" +SPIFF_GENERATED_JWT_KEY_ID = "spiff_backend" +SPIFF_GENERATED_JWT_ALGORITHM = "HS256" +SPIFF_GENERATED_JWT_AUDIENCE = "spiffworkflow-backend" class UserNotFoundError(Exception): @@ -47,6 +52,10 @@ class UserModel(SpiffworkflowBaseDBModel): ) principal = relationship("PrincipalModel", uselist=False, cascade="delete") # type: ignore + @classmethod + def spiff_generated_jwt_issuer(cls) -> str: + return str(current_app.config["SPIFFWORKFLOW_BACKEND_URL"]) + def encode_auth_token(self, extra_payload: dict | None = None) -> str: """Generate the Auth Token. @@ -62,16 +71,17 @@ class UserModel(SpiffworkflowBaseDBModel): "email": self.email, "preferred_username": self.username, "sub": f"service:{self.service}::service_id:{self.service_id}", - "token_type": "internal", + "iss": self.__class__.spiff_generated_jwt_issuer(), + "iat": math.floor(time.time()), + "exp": round(time.time()) + 3600, + "aud": SPIFF_GENERATED_JWT_AUDIENCE, } payload = base_payload if extra_payload is not None: payload = {**base_payload, **extra_payload} return jwt.encode( - payload, - secret_key, - algorithm="HS256", + payload, secret_key, algorithm=SPIFF_GENERATED_JWT_ALGORITHM, headers={"kid": SPIFF_GENERATED_JWT_KEY_ID} ) def as_dict(self) -> dict[str, Any]: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py index a270dd10b..43d185a6e 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py @@ -1,11 +1,8 @@ import ast import base64 -import json import re -from typing import Any import flask -import jwt from flask import current_app from flask import g from flask import jsonify @@ -41,8 +38,14 @@ def authentication_options() -> Response: return make_response(jsonify(response), 200) +# this does both authx and authn +def omni_auth() -> None: + decoded_token = verify_token() + AuthorizationService.check_for_permission(decoded_token) + + # authorization_exclusion_list = ['status'] -def verify_token(token: str | None = None, force_run: bool | None = False) -> None: +def verify_token(token: str | None = None, force_run: bool | None = False) -> dict | None: """Verify the token for the user (if provided). If in production environment and token is not provided, gets user from the SSO headers and returns their token. @@ -67,8 +70,10 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No _clear_auth_tokens_from_thread_local_data() user_model = None + decoded_token = None if token_info["token"] is not None: - user_model = _get_user_model_from_token(token_info["token"]) + decoded_token = _get_decoded_token(token_info["token"]) + user_model = _get_user_model_from_token(decoded_token) elif token_info["api_key"] is not None: user_model = _get_user_model_from_api_key(token_info["api_key"]) @@ -89,7 +94,7 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No get_scope(token_info["token"]) elif token_info["api_key"]: g.authenticated = True - return None + return decoded_token raise ApiError(error_code="invalid_token", message="Cannot validate token.", status_code=401) @@ -120,7 +125,7 @@ def login( AuthenticationService.create_guest_token( username=SPIFF_GUEST_USER, group_identifier=SPIFF_GUEST_GROUP, - auth_token_properties={"only_guest_task_completion": True}, + auth_token_properties={"only_guest_task_completion": True, "process_instance_id": process_instance_id}, authentication_identifier=authentication_identifier, ) return redirect(redirect_url) @@ -139,11 +144,11 @@ def login_return(code: str, state: str, session_state: str = "") -> Response | N auth_token_object = AuthenticationService().get_auth_token_object(code, authentication_identifier=authentication_identifier) if "id_token" in auth_token_object: id_token = auth_token_object["id_token"] - user_info = _parse_id_token(id_token) + decoded_token = _get_decoded_token(id_token) - if AuthenticationService.validate_id_or_access_token(id_token, authentication_identifier=authentication_identifier): - if user_info and "error" not in user_info: - user_model = AuthorizationService.create_user_from_sign_in(user_info) + if AuthenticationService.validate_decoded_token(decoded_token, authentication_identifier=authentication_identifier): + if decoded_token and "error" not in decoded_token: + user_model = AuthorizationService.create_user_from_sign_in(decoded_token) g.user = user_model.id g.token = auth_token_object["id_token"] if "refresh_token" in auth_token_object: @@ -174,11 +179,11 @@ def login_return(code: str, state: str, session_state: str = "") -> Response | N # FIXME: share more code with login_return and maybe attempt to get a refresh token def login_with_access_token(access_token: str, authentication_identifier: str) -> Response: - user_info = _parse_id_token(access_token) + decoded_token = _get_decoded_token(access_token) - if AuthenticationService.validate_id_or_access_token(access_token, authentication_identifier=authentication_identifier): - if user_info and "error" not in user_info: - AuthorizationService.create_user_from_sign_in(user_info) + if AuthenticationService.validate_decoded_token(decoded_token, authentication_identifier=authentication_identifier): + if decoded_token and "error" not in decoded_token: + AuthorizationService.create_user_from_sign_in(decoded_token) else: raise ApiError( error_code="invalid_login", @@ -224,7 +229,7 @@ def logout_return() -> Response: def get_scope(token: str) -> str: scope = "" - decoded_token = jwt.decode(token, options={"verify_signature": False}) + decoded_token = _get_decoded_token(token) if "scope" in decoded_token: scope = decoded_token["scope"] return scope @@ -280,7 +285,7 @@ def _clear_auth_tokens_from_thread_local_data() -> None: delattr(tld, "user_has_logged_out") -def _force_logout_user_if_necessary(user_model: UserModel | None = None) -> bool: +def _force_logout_user_if_necessary(user_model: UserModel | None, decoded_token: dict) -> bool: """Logs out a guest user if certain criteria gets met. * if the user is a no auth guest and we have auth enabled @@ -294,7 +299,7 @@ def _force_logout_user_if_necessary(user_model: UserModel | None = None) -> bool ) or ( user_model.username == SPIFF_GUEST_USER and user_model.service_id == "spiff_guest_service_id" - and not AuthorizationService.request_allows_guest_access() + and not AuthorizationService.request_allows_guest_access(decoded_token) ): AuthenticationService.set_user_has_logged_out() return True @@ -328,88 +333,87 @@ def _get_user_model_from_api_key(api_key: str) -> UserModel | None: return user_model -def _get_user_model_from_token(token: str) -> UserModel | None: +def _get_user_model_from_token(decoded_token: dict) -> UserModel | None: user_model = None - decoded_token = _get_decoded_token(token) if decoded_token is not None: - if "token_type" in decoded_token: - token_type = decoded_token["token_type"] - if token_type == "internal": # noqa: S105 + if "iss" in decoded_token.keys(): + if decoded_token["iss"] == UserModel.spiff_generated_jwt_issuer(): try: user_model = _get_user_from_decoded_internal_token(decoded_token) except Exception as e: current_app.logger.error(f"Exception in verify_token getting user from decoded internal token. {e}") - # if the user is forced logged out then stop processing the token - if _force_logout_user_if_necessary(user_model): - return None + # if the user is forced logged out then stop processing the token + if _force_logout_user_if_necessary(user_model, decoded_token): + return None + else: + user_info = None + authentication_identifier = _get_authentication_identifier_from_request() + try: + if AuthenticationService.validate_decoded_token( + decoded_token, authentication_identifier=authentication_identifier + ): + user_info = decoded_token + except TokenExpiredError as token_expired_error: + # Try to refresh the token + user = UserService.get_user_by_service_and_service_id(decoded_token["iss"], decoded_token["sub"]) + if user: + refresh_token = AuthenticationService.get_refresh_token(user.id) + if refresh_token: + auth_token: dict = AuthenticationService.get_auth_token_from_refresh_token( + refresh_token, authentication_identifier=authentication_identifier + ) + if auth_token and "error" not in auth_token and "id_token" in auth_token: + tld = current_app.config["THREAD_LOCAL_DATA"] + tld.new_access_token = auth_token["id_token"] + tld.new_id_token = auth_token["id_token"] + # We have the user, but this code is a bit convoluted, and will later demand + # a user_info object so it can look up the user. Sorry to leave this crap here. + user_info = { + "sub": user.service_id, + "iss": user.service, + } - elif "iss" in decoded_token.keys(): - user_info = None - authentication_identifier = _get_authentication_identifier_from_request() - try: - if AuthenticationService.validate_id_or_access_token(token, authentication_identifier=authentication_identifier): - user_info = decoded_token - except TokenExpiredError as token_expired_error: - # Try to refresh the token - user = UserService.get_user_by_service_and_service_id(decoded_token["iss"], decoded_token["sub"]) - if user: - refresh_token = AuthenticationService.get_refresh_token(user.id) - if refresh_token: - auth_token: dict = AuthenticationService.get_auth_token_from_refresh_token( - refresh_token, authentication_identifier=authentication_identifier - ) - if auth_token and "error" not in auth_token and "id_token" in auth_token: - tld = current_app.config["THREAD_LOCAL_DATA"] - tld.new_access_token = auth_token["id_token"] - tld.new_id_token = auth_token["id_token"] - # We have the user, but this code is a bit convoluted, and will later demand - # a user_info object so it can look up the user. Sorry to leave this crap here. - user_info = { - "sub": user.service_id, - "iss": user.service, - } + if user_info is None: + AuthenticationService.set_user_has_logged_out() + raise ApiError( + error_code="invalid_token", + message="Your token is expired. Please Login", + status_code=401, + ) from token_expired_error - if user_info is None: + except Exception as e: AuthenticationService.set_user_has_logged_out() raise ApiError( - error_code="invalid_token", - message="Your token is expired. Please Login", + error_code="fail_get_user_info", + message="Cannot get user info from token", status_code=401, - ) from token_expired_error - - except Exception as e: - AuthenticationService.set_user_has_logged_out() - raise ApiError( - error_code="fail_get_user_info", - message="Cannot get user info from token", - status_code=401, - ) from e - if user_info is not None and "error" not in user_info and "iss" in user_info: # not sure what to test yet - user_model = ( - UserModel.query.filter(UserModel.service == user_info["iss"]) - .filter(UserModel.service_id == user_info["sub"]) - .first() - ) - if user_model is None: + ) from e + if user_info is not None and "error" not in user_info and "iss" in user_info: # not sure what to test yet + user_model = ( + UserModel.query.filter(UserModel.service == user_info["iss"]) + .filter(UserModel.service_id == user_info["sub"]) + .first() + ) + if user_model is None: + AuthenticationService.set_user_has_logged_out() + raise ApiError( + error_code="invalid_user", + message="Invalid user. Please log in.", + status_code=401, + ) + # no user_info + else: AuthenticationService.set_user_has_logged_out() raise ApiError( - error_code="invalid_user", - message="Invalid user. Please log in.", + error_code="no_user_info", + message="Cannot retrieve user info", status_code=401, ) - # no user_info - else: - AuthenticationService.set_user_has_logged_out() - raise ApiError( - error_code="no_user_info", - message="Cannot retrieve user info", - status_code=401, - ) else: - current_app.logger.debug("token_type not in decode_token in verify_token") + current_app.logger.debug("iss not in decode_token in verify_token") AuthenticationService.set_user_has_logged_out() raise ApiError( error_code="invalid_token", @@ -432,13 +436,13 @@ def _get_user_from_decoded_internal_token(decoded_token: dict) -> UserModel | No return user -def _get_decoded_token(token: str) -> dict | None: +def _get_decoded_token(token: str) -> dict: try: - decoded_token: dict = jwt.decode(token, options={"verify_signature": False}) + decoded_token: dict = AuthenticationService.parse_jwt_token(_get_authentication_identifier_from_request(), token) except Exception as e: raise ApiError(error_code="invalid_token", message="Cannot decode token.") from e else: - if "token_type" in decoded_token or "iss" in decoded_token: + if "iss" in decoded_token: return decoded_token else: current_app.logger.error(f"Unknown token type in get_decoded_token: token: {token}") @@ -448,20 +452,6 @@ def _get_decoded_token(token: str) -> dict | None: ) -def _parse_id_token(token: str) -> Any: - """Parse the id token.""" - parts = token.split(".") - if len(parts) != 3: - raise Exception("Incorrect id token format") - - payload = parts[1] - padded = payload + "=" * (4 - len(payload) % 4) - - # https://lists.jboss.org/pipermail/keycloak-user/2016-April/005758.html - decoded = base64.urlsafe_b64decode(padded) - return json.loads(decoded) - - def _get_authentication_identifier_from_request() -> str: if "authentication_identifier" in request.cookies: return request.cookies["authentication_identifier"] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py index 023619d74..ee4ea945c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py @@ -7,6 +7,7 @@ This is just here to make local development, testing, and demonstration easier. """ import base64 import json +import math import time from typing import Any from urllib.parse import urlencode @@ -15,15 +16,21 @@ import jwt import yaml from flask import Blueprint from flask import current_app +from flask import jsonify +from flask import make_response from flask import redirect from flask import render_template from flask import request from flask import url_for from werkzeug.wrappers import Response +from spiffworkflow_backend.config.openid.rsa_keys import OpenIdConfigsForDevOnly + openid_blueprint = Blueprint("openid", __name__, template_folder="templates", static_folder="static") OPEN_ID_CODE = ":this_is_not_secure_do_not_use_in_production" +SPIFF_OPEN_ID_KEY_ID = "spiffworkflow_backend_open_id" +SPIFF_OPEN_ID_ALGORITHM = "RS256" @openid_blueprint.route("/.well-known/openid-configuration", methods=["GET"]) @@ -38,6 +45,7 @@ def well_known() -> dict: "authorization_endpoint": f"{host_url}{url_for('openid.auth')}", "token_endpoint": f"{host_url}{url_for('openid.token')}", "end_session_endpoint": f"{host_url}{url_for('openid.end_session')}", + "jwks_uri": f"{host_url}{url_for('openid.jwks')}", } @@ -97,22 +105,24 @@ def token() -> Response | dict: authorization = request.headers.get("Authorization", "Basic ") authorization = authorization[6:] # Remove "Basic" authorization = base64.b64decode(authorization).decode("utf-8") - client_id, client_secret = authorization.split(":") + client_id = authorization.split(":") base_url = request.host_url + "openid" + private_key = OpenIdConfigsForDevOnly.private_key id_token = jwt.encode( { "iss": base_url, - "aud": [client_id, "account"], - "iat": time.time(), - "exp": time.time() + 86400, # Expire after a day. + "aud": client_id, + "iat": math.floor(time.time()), + "exp": round(time.time()) + 3600, "sub": user_name, "email": user_details["email"], "preferred_username": user_details.get("preferred_username", user_name), }, - client_secret, - algorithm="HS256", + private_key, + algorithm=SPIFF_OPEN_ID_ALGORITHM, + headers={"kid": SPIFF_OPEN_ID_KEY_ID}, ) response = { "access_token": id_token, @@ -129,6 +139,21 @@ def end_session() -> Response: return redirect(redirect_url) +@openid_blueprint.route("/jwks", methods=["GET"]) +def jwks() -> Response: + public_key = base64.b64encode(OpenIdConfigsForDevOnly.public_key.encode()).decode("utf-8") + jwks_configs = { + "keys": [ + { + "kid": SPIFF_OPEN_ID_KEY_ID, + "kty": "RSA", + "x5c": [public_key], + } + ] + } + return make_response(jsonify(jwks_configs), 200) + + @openid_blueprint.route("/refresh", methods=["POST"]) def refresh() -> str: return "" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py index f12860983..87531ba15 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py @@ -6,6 +6,16 @@ import time from hashlib import sha256 from hmac import HMAC from hmac import compare_digest +from typing import Any + +from cryptography.hazmat.backends import default_backend +from cryptography.x509 import load_der_x509_certificate + +from spiffworkflow_backend.models.user import SPIFF_GENERATED_JWT_ALGORITHM +from spiffworkflow_backend.models.user import SPIFF_GENERATED_JWT_AUDIENCE +from spiffworkflow_backend.models.user import SPIFF_GENERATED_JWT_KEY_ID +from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import SPIFF_OPEN_ID_KEY_ID if sys.version_info < (3, 11): from typing_extensions import NotRequired @@ -57,6 +67,7 @@ class AuthenticationOptionNotFoundError(Exception): class AuthenticationService: ENDPOINT_CACHE: dict[str, dict[str, str]] = {} # We only need to find the openid endpoints once, then we can cache them. + JSON_WEB_KEYSET_CACHE: dict[str, dict[str, str]] = {} @classmethod def authentication_options_for_api(cls) -> list[AuthenticationOptionForApi]: @@ -86,6 +97,10 @@ class AuthenticationService: config: str = cls.authentication_option_for_identifier(authentication_identifier)["client_id"] return config + @classmethod + def valid_audiences(cls, authentication_identifier: str) -> list[str]: + return [cls.client_id(authentication_identifier)] + @classmethod def server_url(cls, authentication_identifier: str) -> str: """Returns the server url from the config.""" @@ -104,6 +119,8 @@ class AuthenticationService: openid_config_url = f"{cls.server_url(authentication_identifier)}/.well-known/openid-configuration" if authentication_identifier not in cls.ENDPOINT_CACHE: cls.ENDPOINT_CACHE[authentication_identifier] = {} + if authentication_identifier not in cls.JSON_WEB_KEYSET_CACHE: + cls.JSON_WEB_KEYSET_CACHE[authentication_identifier] = {} if name not in AuthenticationService.ENDPOINT_CACHE[authentication_identifier]: try: response = requests.get(openid_config_url, timeout=HTTP_REQUEST_TIMEOUT_SECONDS) @@ -115,6 +132,63 @@ class AuthenticationService: config: str = AuthenticationService.ENDPOINT_CACHE[authentication_identifier].get(name, "") return config + @classmethod + def get_jwks_config_from_uri(cls, jwks_uri: str) -> dict: + if jwks_uri not in AuthenticationService.JSON_WEB_KEYSET_CACHE: + try: + jwt_ks_response = requests.get(jwks_uri, timeout=HTTP_REQUEST_TIMEOUT_SECONDS) + AuthenticationService.JSON_WEB_KEYSET_CACHE[jwks_uri] = jwt_ks_response.json() + except requests.exceptions.ConnectionError as ce: + raise OpenIdConnectionError(f"Cannot connect to given jwks url: {jwks_uri}") from ce + return AuthenticationService.JSON_WEB_KEYSET_CACHE[jwks_uri] + + @classmethod + def jwks_public_key_for_key_id(cls, authentication_identifier: str, key_id: str) -> dict: + jwks_uri = cls.open_id_endpoint_for_name("jwks_uri", authentication_identifier) + jwks_configs = cls.get_jwks_config_from_uri(jwks_uri) + json_key_configs: dict = next(jk for jk in jwks_configs["keys"] if jk["kid"] == key_id) + return json_key_configs + + @classmethod + def parse_jwt_token(cls, authentication_identifier: str, token: str) -> dict: + header = jwt.get_unverified_header(token) + key_id = str(header.get("kid")) + + # if the token has our key id then we issued it and should verify to ensure it's valid + if key_id == SPIFF_GENERATED_JWT_KEY_ID: + return jwt.decode( + token, + str(current_app.secret_key), + algorithms=[SPIFF_GENERATED_JWT_ALGORITHM], + audience=SPIFF_GENERATED_JWT_AUDIENCE, + options={"verify_exp": False}, + ) + else: + json_key_configs = cls.jwks_public_key_for_key_id(authentication_identifier, key_id) + x5c = json_key_configs["x5c"][0] + algorithm = str(header.get("alg")) + decoded_certificate = base64.b64decode(x5c) + + # our backend-based openid provider implementation (which you should never use in prod) + # uses a public/private key pair. we played around with adding an x509 cert so we could + # follow the exact same mechanism for getting the public key that we use for keycloak, + # but using an x509 cert for no reason seemed a little overboard for this toy-openid use case, + # when we already have the public key that can work hardcoded in our config. + public_key: Any = None + if key_id == SPIFF_OPEN_ID_KEY_ID: + public_key = decoded_certificate + else: + x509_cert = load_der_x509_certificate(decoded_certificate, default_backend()) + public_key = x509_cert.public_key() + + return jwt.decode( + token, + public_key, + algorithms=[algorithm], + audience=cls.valid_audiences(authentication_identifier)[0], + options={"verify_exp": False}, + ) + @staticmethod def get_backend_url() -> str: return str(current_app.config["SPIFFWORKFLOW_BACKEND_URL"]) @@ -177,7 +251,7 @@ class AuthenticationService: if azp is None: return True - valid_client_ids = [cls.client_id(authentication_identifier), "account"] + valid_client_ids = [cls.client_id(authentication_identifier)] if ( "additional_valid_client_ids" in cls.authentication_option_for_identifier(authentication_identifier) and cls.authentication_option_for_identifier(authentication_identifier)["additional_valid_client_ids"] is not None @@ -190,30 +264,26 @@ class AuthenticationService: return azp in valid_client_ids @classmethod - def validate_id_or_access_token(cls, token: str, authentication_identifier: str) -> bool: + def validate_decoded_token(cls, decoded_token: dict, authentication_identifier: str) -> bool: """Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation.""" valid = True now = round(time.time()) - try: - decoded_token = jwt.decode(token, options={"verify_signature": False}) - except Exception as e: - raise TokenInvalidError("Cannot decode token") from e # give a 5 second leeway to iat in case keycloak server time doesn't match backend server iat_clock_skew_leeway = 5 iss = decoded_token["iss"] - aud = decoded_token["aud"] + aud = decoded_token["aud"] if "aud" in decoded_token else None azp = decoded_token["azp"] if "azp" in decoded_token else None iat = decoded_token["iat"] - valid_audience_values = (cls.client_id(authentication_identifier), "account") + valid_audience_values = cls.valid_audiences(authentication_identifier) audience_array_in_token = aud if isinstance(aud, str): audience_array_in_token = [aud] overlapping_aud_values = [x for x in audience_array_in_token if x in valid_audience_values] - if iss != cls.server_url(authentication_identifier): + if iss not in [cls.server_url(authentication_identifier), UserModel.spiff_generated_jwt_issuer()]: current_app.logger.error( f"TOKEN INVALID because ISS '{iss}' does not match server url '{cls.server_url(authentication_identifier)}'" ) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index 1563308ea..d7b204569 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -30,6 +30,7 @@ from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel from spiffworkflow_backend.models.permission_target import PermissionTargetModel from spiffworkflow_backend.models.principal import PrincipalModel +from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.user import SPIFF_GUEST_USER @@ -281,7 +282,7 @@ class AuthorizationService: return None @classmethod - def check_for_permission(cls) -> None: + def check_for_permission(cls, decoded_token: dict | None) -> None: if cls.should_disable_auth_for_request(): return None @@ -293,7 +294,7 @@ class AuthorizationService: if cls.request_is_excluded_from_permission_check(): return None - if cls.request_allows_guest_access(): + if cls.request_allows_guest_access(decoded_token): return None permission_string = cls.get_permission_from_http_method(request.method) @@ -319,7 +320,7 @@ class AuthorizationService: return False @classmethod - def request_allows_guest_access(cls) -> bool: + def request_allows_guest_access(cls, decoded_token: dict | None) -> bool: if cls.request_is_excluded_from_permission_check(): return True @@ -329,6 +330,18 @@ class AuthorizationService: task_guid = request.path.split("/")[4] if TaskModel.task_guid_allows_guest(task_guid, process_instance_id): return True + + if ( + decoded_token is not None + and "process_instance_id" in decoded_token + and "only_guest_task_completion" in decoded_token + and decoded_token["only_guest_task_completion"] is True + and api_view_function.__name__ == "typeahead" + and api_view_function.__module__ == "spiffworkflow_backend.routes.connector_proxy_controller" + ): + process_instance = ProcessInstanceModel.query.filter_by(id=decoded_token["process_instance_id"]).first() + if process_instance is not None and not process_instance.has_terminal_status(): + return True return False @staticmethod diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py index d93227d65..13ad65751 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py @@ -1,7 +1,6 @@ import ast import base64 import re -import time from flask.app import Flask from flask.testing import FlaskClient @@ -45,8 +44,6 @@ class TestAuthentication(BaseTest): "groups": ["group_one", "group_two"], "iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"], "aud": "spiffworkflow-backend", - "iat": round(time.time()), - "exp": round(time.time()) + 1000, } ) response = None @@ -63,8 +60,6 @@ class TestAuthentication(BaseTest): "groups": ["group_one"], "iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"], "aud": "spiffworkflow-backend", - "iat": round(time.time()), - "exp": round(time.time()) + 1000, } ) response = client.post(