Feature/typeahead allow guest user (#897)
* decode tokens with jwt instead of with base64 w/ burnettk * try to verify jwt token with keycloak when we decode it w/ burnettk * make the token algorithm a constant w/ burnettk * WIP: create more valid looking jwt from spiff w/ burnettk * tests are passsing now w/ burnettk * some pyl stuff w/ burnettk * fixed mypy issues w/ burnettk * fixed issues from mypy fixes w/ burnettk * do not load openid blueprint if not using those configs w/ burnettk * used the process instance to determine if guest user can use connector api w/ burnettk * only check the db for process instance if the api call is for typeahead * removed unused test code * pyl --------- Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
parent
2d9fb4430e
commit
80bc2f2e42
17
bin/run_pyl
17
bin/run_pyl
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
function error_handler() {
|
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"
|
exit "$2"
|
||||||
}
|
}
|
||||||
trap 'error_handler ${LINENO} $?' ERR
|
trap 'error_handler ${LINENO} $?' ERR
|
||||||
|
@ -31,23 +31,16 @@ function get_python_dirs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function run_autofixers() {
|
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"
|
python_dirs="$(get_python_dirs) bin"
|
||||||
# shellcheck disable=2086
|
# shellcheck disable=2086
|
||||||
ruff --fix $python_dirs || echo ''
|
poetry run ruff --fix $python_dirs || echo ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function run_pre_commmit() {
|
function run_pre_commmit() {
|
||||||
poetry run pre-commit run --verbose --all-files
|
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 pre, only do stuff when there are changes
|
||||||
if [[ -n "$(git status --porcelain "$react_project")" ]]; then
|
if [[ -n "$(git status --porcelain "$react_project")" ]]; then
|
||||||
pushd "$react_project"
|
pushd "$react_project"
|
||||||
|
@ -56,7 +49,7 @@ for react_project in "${react_projects[@]}" ; do
|
||||||
fi
|
fi
|
||||||
done
|
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 pre, only do stuff when there are changes
|
||||||
if [[ "$subcommand" != "pre" ]] || [[ -n "$(git status --porcelain "$python_project")" ]]; then
|
if [[ "$subcommand" != "pre" ]] || [[ -n "$(git status --porcelain "$python_project")" ]]; then
|
||||||
pushd "$python_project"
|
pushd "$python_project"
|
||||||
|
@ -73,7 +66,7 @@ fi
|
||||||
function clear_log_file() {
|
function clear_log_file() {
|
||||||
unit_testing_log_file="./log/unit_testing.log"
|
unit_testing_log_file="./log/unit_testing.log"
|
||||||
if [[ -f "$unit_testing_log_file" ]]; then
|
if [[ -f "$unit_testing_log_file" ]]; then
|
||||||
> "$unit_testing_log_file"
|
>"$unit_testing_log_file"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
function error_handler() {
|
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"
|
exit "$2"
|
||||||
}
|
}
|
||||||
trap 'error_handler ${LINENO} $?' ERR
|
trap 'error_handler ${LINENO} $?' ERR
|
||||||
|
@ -19,7 +19,6 @@ if [[ -d "$process_model_dir" ]]; then
|
||||||
shift
|
shift
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
if [[ -z "${SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR:-}" ]]; then
|
if [[ -z "${SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR:-}" ]]; then
|
||||||
if [[ -n "${process_model_dir}" ]] && [[ -d "${process_model_dir}" ]]; then
|
if [[ -n "${process_model_dir}" ]] && [[ -d "${process_model_dir}" ]]; then
|
||||||
SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR="$process_model_dir"
|
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_LOAD_FIXTURE_DATA=true
|
||||||
export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=acceptance_tests.yml
|
export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=acceptance_tests.yml
|
||||||
elif [[ "$process_model_dir" == "localopenid" ]]; then
|
elif [[ "$process_model_dir" == "localopenid" ]]; then
|
||||||
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="openid"
|
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="default"
|
||||||
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="openid"
|
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__uri="http://localhost:$port/openid"
|
||||||
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_id="spiffworkflow-backend"
|
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_id="spiffworkflow-backend"
|
||||||
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q"
|
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q"
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
function error_handler() {
|
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"
|
exit "$2"
|
||||||
}
|
}
|
||||||
trap 'error_handler ${LINENO} $?' ERR
|
trap 'error_handler ${LINENO} $?' ERR
|
||||||
set -o errtrace -o errexit -o nounset -o pipefail
|
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"
|
. "${script_dir}/local_development_environment_setup"
|
||||||
|
|
||||||
server_type="${1:-api}"
|
server_type="${1:-api}"
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
function error_handler() {
|
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"
|
exit "$2"
|
||||||
}
|
}
|
||||||
trap 'error_handler ${LINENO} $?' ERR
|
trap 'error_handler ${LINENO} $?' ERR
|
||||||
set -o errtrace -o errexit -o nounset -o pipefail
|
set -o errtrace -o errexit -o nounset -o pipefail
|
||||||
|
|
||||||
if [[ ! -f ./src/instance/db_unit_testing_gw0.sqlite3 ]] ; then
|
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"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# check if python package pytest-xdist is installed
|
# check if python package pytest-xdist is installed
|
||||||
if ! python -c "import xdist" &>/dev/null; then
|
if ! poetry run python -c "import xdist" &>/dev/null; then
|
||||||
>&2 echo -e "ERROR: please install the python package pytest-xdist by running poetry install"
|
echo >&2 -e "ERROR: please install the python package pytest-xdist by running poetry install"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
@ -23,12 +23,16 @@ if os.environ.get("RUN_TYPEGUARD") == "true":
|
||||||
from spiffworkflow_backend import create_app # noqa: E402
|
from spiffworkflow_backend import create_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
def _set_unit_testing_env_variables() -> None:
|
||||||
def app() -> Flask: # noqa
|
|
||||||
os.environ["SPIFFWORKFLOW_BACKEND_ENV"] = "unit_testing"
|
os.environ["SPIFFWORKFLOW_BACKEND_ENV"] = "unit_testing"
|
||||||
os.environ["FLASK_SESSION_SECRET_KEY"] = (
|
os.environ["FLASK_SESSION_SECRET_KEY"] = (
|
||||||
"e7711a3ba96c46c68e084a86952de16f" # noqa: S105, do not care about security when running unit tests
|
"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()
|
app = create_app()
|
||||||
|
|
||||||
# to screw with this, poet add nplusone --group dev
|
# to screw with this, poet add nplusone --group dev
|
||||||
|
|
|
@ -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 db
|
||||||
from spiffworkflow_backend.models.db import migrate
|
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 _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.openid_blueprint.openid_blueprint import openid_blueprint
|
||||||
from spiffworkflow_backend.routes.user_blueprint import user_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 configure_sentry
|
||||||
from spiffworkflow_backend.services.monitoring_service import setup_prometheus_metrics
|
from spiffworkflow_backend.services.monitoring_service import setup_prometheus_metrics
|
||||||
|
|
||||||
|
@ -72,6 +71,10 @@ def create_app() -> flask.app.Flask:
|
||||||
|
|
||||||
app.register_blueprint(user_blueprint)
|
app.register_blueprint(user_blueprint)
|
||||||
app.register_blueprint(api_error_blueprint)
|
app.register_blueprint(api_error_blueprint)
|
||||||
|
|
||||||
|
# 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")
|
app.register_blueprint(openid_blueprint, url_prefix="/openid")
|
||||||
|
|
||||||
# preflight options requests will be allowed if they meet the requirements of the url regex.
|
# preflight options requests will be allowed if they meet the requirements of the url regex.
|
||||||
|
@ -89,8 +92,7 @@ def create_app() -> flask.app.Flask:
|
||||||
|
|
||||||
configure_sentry(app)
|
configure_sentry(app)
|
||||||
|
|
||||||
app.before_request(verify_token)
|
app.before_request(omni_auth)
|
||||||
app.before_request(AuthorizationService.check_for_permission)
|
|
||||||
app.after_request(_set_new_access_token_in_cookie)
|
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
|
# The default is true, but we want to preserve the order of keys in the json
|
||||||
|
|
|
@ -714,8 +714,7 @@ paths:
|
||||||
minItems: 1
|
minItems: 1
|
||||||
get:
|
get:
|
||||||
operationId: spiffworkflow_backend.routes.process_api_blueprint.process_caller_list
|
operationId: spiffworkflow_backend.routes.process_api_blueprint.process_caller_list
|
||||||
summary:
|
summary: Return a list of information about all processes that call the provided process id
|
||||||
Return a list of information about all processes that call the provided process id
|
|
||||||
tags:
|
tags:
|
||||||
- Process Models
|
- Process Models
|
||||||
responses:
|
responses:
|
||||||
|
@ -728,7 +727,6 @@ paths:
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/Process"
|
$ref: "#/components/schemas/Process"
|
||||||
|
|
||||||
|
|
||||||
/processes:
|
/processes:
|
||||||
get:
|
get:
|
||||||
operationId: spiffworkflow_backend.routes.process_api_blueprint.process_list
|
operationId: spiffworkflow_backend.routes.process_api_blueprint.process_list
|
||||||
|
@ -2231,7 +2229,6 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Task"
|
$ref: "#/components/schemas/Task"
|
||||||
|
|
||||||
|
|
||||||
/tasks/{process_instance_id}/instruction:
|
/tasks/{process_instance_id}/instruction:
|
||||||
parameters:
|
parameters:
|
||||||
- name: process_instance_id
|
- name: process_instance_id
|
||||||
|
@ -2827,16 +2824,8 @@ paths:
|
||||||
"200":
|
"200":
|
||||||
description: A list of the data stored in the requested data store.
|
description: A list of the data stored in the requested data store.
|
||||||
|
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
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:
|
oAuth2AuthCode:
|
||||||
type: oauth2
|
type: oauth2
|
||||||
description: authenticate with openid server
|
description: authenticate with openid server
|
||||||
|
|
|
@ -181,6 +181,7 @@ def setup_config(app: Flask) -> None:
|
||||||
|
|
||||||
env_config_prefix = "spiffworkflow_backend.config."
|
env_config_prefix = "spiffworkflow_backend.config."
|
||||||
env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"]
|
env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"]
|
||||||
|
|
||||||
load_config_file(app, env_config_module)
|
load_config_file(app, env_config_module)
|
||||||
|
|
||||||
# This allows config/testing.py or instance/config.py to override the default config
|
# 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"]
|
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"] = [
|
app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"] = [
|
||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
|
|
|
@ -127,6 +127,7 @@ else:
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
### logs
|
### logs
|
||||||
# loggers to use is a comma separated list of logger prefixes that we will be converted to list of strings
|
# 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")
|
config_from_env("SPIFFWORKFLOW_BACKEND_LOGGERS_TO_USE")
|
||||||
|
|
|
@ -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-----"""
|
|
@ -9,6 +9,11 @@ SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME = environ.get(
|
||||||
"SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME", default="unit_testing.yml"
|
"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_LOG_LEVEL = environ.get("SPIFFWORKFLOW_BACKEND_LOG_LEVEL", default="debug")
|
||||||
SPIFFWORKFLOW_BACKEND_GIT_COMMIT_ON_SAVE = False
|
SPIFFWORKFLOW_BACKEND_GIT_COMMIT_ON_SAVE = False
|
||||||
|
|
||||||
|
|
|
@ -323,5 +323,4 @@ def handle_exception(exception: Exception) -> flask.wrappers.Response:
|
||||||
if api_exception.response_headers is not None:
|
if api_exception.response_headers is not None:
|
||||||
for header, value in api_exception.response_headers.items():
|
for header, value in api_exception.response_headers.items():
|
||||||
error_response.headers[header] = value
|
error_response.headers[header] = value
|
||||||
|
|
||||||
return error_response
|
return error_response
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
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_NO_AUTH_USER = "spiff_no_auth_guest_user"
|
||||||
SPIFF_GUEST_USER = "spiff_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):
|
class UserNotFoundError(Exception):
|
||||||
|
@ -47,6 +52,10 @@ class UserModel(SpiffworkflowBaseDBModel):
|
||||||
)
|
)
|
||||||
principal = relationship("PrincipalModel", uselist=False, cascade="delete") # type: ignore
|
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:
|
def encode_auth_token(self, extra_payload: dict | None = None) -> str:
|
||||||
"""Generate the Auth Token.
|
"""Generate the Auth Token.
|
||||||
|
|
||||||
|
@ -62,16 +71,17 @@ class UserModel(SpiffworkflowBaseDBModel):
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"preferred_username": self.username,
|
"preferred_username": self.username,
|
||||||
"sub": f"service:{self.service}::service_id:{self.service_id}",
|
"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
|
payload = base_payload
|
||||||
if extra_payload is not None:
|
if extra_payload is not None:
|
||||||
payload = {**base_payload, **extra_payload}
|
payload = {**base_payload, **extra_payload}
|
||||||
return jwt.encode(
|
return jwt.encode(
|
||||||
payload,
|
payload, secret_key, algorithm=SPIFF_GENERATED_JWT_ALGORITHM, headers={"kid": SPIFF_GENERATED_JWT_KEY_ID}
|
||||||
secret_key,
|
|
||||||
algorithm="HS256",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import ast
|
import ast
|
||||||
import base64
|
import base64
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
import jwt
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask import g
|
from flask import g
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
|
@ -41,8 +38,14 @@ def authentication_options() -> Response:
|
||||||
return make_response(jsonify(response), 200)
|
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']
|
# 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).
|
"""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.
|
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()
|
_clear_auth_tokens_from_thread_local_data()
|
||||||
|
|
||||||
user_model = None
|
user_model = None
|
||||||
|
decoded_token = None
|
||||||
if token_info["token"] is not 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:
|
elif token_info["api_key"] is not None:
|
||||||
user_model = _get_user_model_from_api_key(token_info["api_key"])
|
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"])
|
get_scope(token_info["token"])
|
||||||
elif token_info["api_key"]:
|
elif token_info["api_key"]:
|
||||||
g.authenticated = True
|
g.authenticated = True
|
||||||
return None
|
return decoded_token
|
||||||
|
|
||||||
raise ApiError(error_code="invalid_token", message="Cannot validate token.", status_code=401)
|
raise ApiError(error_code="invalid_token", message="Cannot validate token.", status_code=401)
|
||||||
|
|
||||||
|
@ -120,7 +125,7 @@ def login(
|
||||||
AuthenticationService.create_guest_token(
|
AuthenticationService.create_guest_token(
|
||||||
username=SPIFF_GUEST_USER,
|
username=SPIFF_GUEST_USER,
|
||||||
group_identifier=SPIFF_GUEST_GROUP,
|
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,
|
authentication_identifier=authentication_identifier,
|
||||||
)
|
)
|
||||||
return redirect(redirect_url)
|
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)
|
auth_token_object = AuthenticationService().get_auth_token_object(code, authentication_identifier=authentication_identifier)
|
||||||
if "id_token" in auth_token_object:
|
if "id_token" in auth_token_object:
|
||||||
id_token = auth_token_object["id_token"]
|
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 AuthenticationService.validate_decoded_token(decoded_token, authentication_identifier=authentication_identifier):
|
||||||
if user_info and "error" not in user_info:
|
if decoded_token and "error" not in decoded_token:
|
||||||
user_model = AuthorizationService.create_user_from_sign_in(user_info)
|
user_model = AuthorizationService.create_user_from_sign_in(decoded_token)
|
||||||
g.user = user_model.id
|
g.user = user_model.id
|
||||||
g.token = auth_token_object["id_token"]
|
g.token = auth_token_object["id_token"]
|
||||||
if "refresh_token" in auth_token_object:
|
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
|
# 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:
|
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 AuthenticationService.validate_decoded_token(decoded_token, authentication_identifier=authentication_identifier):
|
||||||
if user_info and "error" not in user_info:
|
if decoded_token and "error" not in decoded_token:
|
||||||
AuthorizationService.create_user_from_sign_in(user_info)
|
AuthorizationService.create_user_from_sign_in(decoded_token)
|
||||||
else:
|
else:
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
error_code="invalid_login",
|
error_code="invalid_login",
|
||||||
|
@ -224,7 +229,7 @@ def logout_return() -> Response:
|
||||||
|
|
||||||
def get_scope(token: str) -> str:
|
def get_scope(token: str) -> str:
|
||||||
scope = ""
|
scope = ""
|
||||||
decoded_token = jwt.decode(token, options={"verify_signature": False})
|
decoded_token = _get_decoded_token(token)
|
||||||
if "scope" in decoded_token:
|
if "scope" in decoded_token:
|
||||||
scope = decoded_token["scope"]
|
scope = decoded_token["scope"]
|
||||||
return scope
|
return scope
|
||||||
|
@ -280,7 +285,7 @@ def _clear_auth_tokens_from_thread_local_data() -> None:
|
||||||
delattr(tld, "user_has_logged_out")
|
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.
|
"""Logs out a guest user if certain criteria gets met.
|
||||||
|
|
||||||
* if the user is a no auth guest and we have auth enabled
|
* 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 (
|
) or (
|
||||||
user_model.username == SPIFF_GUEST_USER
|
user_model.username == SPIFF_GUEST_USER
|
||||||
and user_model.service_id == "spiff_guest_service_id"
|
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()
|
AuthenticationService.set_user_has_logged_out()
|
||||||
return True
|
return True
|
||||||
|
@ -328,28 +333,27 @@ def _get_user_model_from_api_key(api_key: str) -> UserModel | None:
|
||||||
return user_model
|
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
|
user_model = None
|
||||||
decoded_token = _get_decoded_token(token)
|
|
||||||
|
|
||||||
if decoded_token is not None:
|
if decoded_token is not None:
|
||||||
if "token_type" in decoded_token:
|
if "iss" in decoded_token.keys():
|
||||||
token_type = decoded_token["token_type"]
|
if decoded_token["iss"] == UserModel.spiff_generated_jwt_issuer():
|
||||||
if token_type == "internal": # noqa: S105
|
|
||||||
try:
|
try:
|
||||||
user_model = _get_user_from_decoded_internal_token(decoded_token)
|
user_model = _get_user_from_decoded_internal_token(decoded_token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Exception in verify_token getting user from decoded internal token. {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 the user is forced logged out then stop processing the token
|
||||||
if _force_logout_user_if_necessary(user_model):
|
if _force_logout_user_if_necessary(user_model, decoded_token):
|
||||||
return None
|
return None
|
||||||
|
else:
|
||||||
elif "iss" in decoded_token.keys():
|
|
||||||
user_info = None
|
user_info = None
|
||||||
authentication_identifier = _get_authentication_identifier_from_request()
|
authentication_identifier = _get_authentication_identifier_from_request()
|
||||||
try:
|
try:
|
||||||
if AuthenticationService.validate_id_or_access_token(token, authentication_identifier=authentication_identifier):
|
if AuthenticationService.validate_decoded_token(
|
||||||
|
decoded_token, authentication_identifier=authentication_identifier
|
||||||
|
):
|
||||||
user_info = decoded_token
|
user_info = decoded_token
|
||||||
except TokenExpiredError as token_expired_error:
|
except TokenExpiredError as token_expired_error:
|
||||||
# Try to refresh the token
|
# Try to refresh the token
|
||||||
|
@ -409,7 +413,7 @@ def _get_user_model_from_token(token: str) -> UserModel | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
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()
|
AuthenticationService.set_user_has_logged_out()
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
error_code="invalid_token",
|
error_code="invalid_token",
|
||||||
|
@ -432,13 +436,13 @@ def _get_user_from_decoded_internal_token(decoded_token: dict) -> UserModel | No
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get_decoded_token(token: str) -> dict | None:
|
def _get_decoded_token(token: str) -> dict:
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
raise ApiError(error_code="invalid_token", message="Cannot decode token.") from e
|
raise ApiError(error_code="invalid_token", message="Cannot decode token.") from e
|
||||||
else:
|
else:
|
||||||
if "token_type" in decoded_token or "iss" in decoded_token:
|
if "iss" in decoded_token:
|
||||||
return decoded_token
|
return decoded_token
|
||||||
else:
|
else:
|
||||||
current_app.logger.error(f"Unknown token type in get_decoded_token: token: {token}")
|
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:
|
def _get_authentication_identifier_from_request() -> str:
|
||||||
if "authentication_identifier" in request.cookies:
|
if "authentication_identifier" in request.cookies:
|
||||||
return request.cookies["authentication_identifier"]
|
return request.cookies["authentication_identifier"]
|
||||||
|
|
|
@ -7,6 +7,7 @@ This is just here to make local development, testing, and demonstration easier.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
@ -15,15 +16,21 @@ import jwt
|
||||||
import yaml
|
import yaml
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
from flask import jsonify
|
||||||
|
from flask import make_response
|
||||||
from flask import redirect
|
from flask import redirect
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from werkzeug.wrappers import Response
|
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")
|
openid_blueprint = Blueprint("openid", __name__, template_folder="templates", static_folder="static")
|
||||||
|
|
||||||
OPEN_ID_CODE = ":this_is_not_secure_do_not_use_in_production"
|
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"])
|
@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')}",
|
"authorization_endpoint": f"{host_url}{url_for('openid.auth')}",
|
||||||
"token_endpoint": f"{host_url}{url_for('openid.token')}",
|
"token_endpoint": f"{host_url}{url_for('openid.token')}",
|
||||||
"end_session_endpoint": f"{host_url}{url_for('openid.end_session')}",
|
"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 = request.headers.get("Authorization", "Basic ")
|
||||||
authorization = authorization[6:] # Remove "Basic"
|
authorization = authorization[6:] # Remove "Basic"
|
||||||
authorization = base64.b64decode(authorization).decode("utf-8")
|
authorization = base64.b64decode(authorization).decode("utf-8")
|
||||||
client_id, client_secret = authorization.split(":")
|
client_id = authorization.split(":")
|
||||||
|
|
||||||
base_url = request.host_url + "openid"
|
base_url = request.host_url + "openid"
|
||||||
|
private_key = OpenIdConfigsForDevOnly.private_key
|
||||||
|
|
||||||
id_token = jwt.encode(
|
id_token = jwt.encode(
|
||||||
{
|
{
|
||||||
"iss": base_url,
|
"iss": base_url,
|
||||||
"aud": [client_id, "account"],
|
"aud": client_id,
|
||||||
"iat": time.time(),
|
"iat": math.floor(time.time()),
|
||||||
"exp": time.time() + 86400, # Expire after a day.
|
"exp": round(time.time()) + 3600,
|
||||||
"sub": user_name,
|
"sub": user_name,
|
||||||
"email": user_details["email"],
|
"email": user_details["email"],
|
||||||
"preferred_username": user_details.get("preferred_username", user_name),
|
"preferred_username": user_details.get("preferred_username", user_name),
|
||||||
},
|
},
|
||||||
client_secret,
|
private_key,
|
||||||
algorithm="HS256",
|
algorithm=SPIFF_OPEN_ID_ALGORITHM,
|
||||||
|
headers={"kid": SPIFF_OPEN_ID_KEY_ID},
|
||||||
)
|
)
|
||||||
response = {
|
response = {
|
||||||
"access_token": id_token,
|
"access_token": id_token,
|
||||||
|
@ -129,6 +139,21 @@ def end_session() -> Response:
|
||||||
return redirect(redirect_url)
|
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"])
|
@openid_blueprint.route("/refresh", methods=["POST"])
|
||||||
def refresh() -> str:
|
def refresh() -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -6,6 +6,16 @@ import time
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from hmac import HMAC
|
from hmac import HMAC
|
||||||
from hmac import compare_digest
|
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):
|
if sys.version_info < (3, 11):
|
||||||
from typing_extensions import NotRequired
|
from typing_extensions import NotRequired
|
||||||
|
@ -57,6 +67,7 @@ class AuthenticationOptionNotFoundError(Exception):
|
||||||
|
|
||||||
class AuthenticationService:
|
class AuthenticationService:
|
||||||
ENDPOINT_CACHE: dict[str, dict[str, str]] = {} # We only need to find the openid endpoints once, then we can cache them.
|
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
|
@classmethod
|
||||||
def authentication_options_for_api(cls) -> list[AuthenticationOptionForApi]:
|
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"]
|
config: str = cls.authentication_option_for_identifier(authentication_identifier)["client_id"]
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def valid_audiences(cls, authentication_identifier: str) -> list[str]:
|
||||||
|
return [cls.client_id(authentication_identifier)]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def server_url(cls, authentication_identifier: str) -> str:
|
def server_url(cls, authentication_identifier: str) -> str:
|
||||||
"""Returns the server url from the config."""
|
"""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"
|
openid_config_url = f"{cls.server_url(authentication_identifier)}/.well-known/openid-configuration"
|
||||||
if authentication_identifier not in cls.ENDPOINT_CACHE:
|
if authentication_identifier not in cls.ENDPOINT_CACHE:
|
||||||
cls.ENDPOINT_CACHE[authentication_identifier] = {}
|
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]:
|
if name not in AuthenticationService.ENDPOINT_CACHE[authentication_identifier]:
|
||||||
try:
|
try:
|
||||||
response = requests.get(openid_config_url, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
|
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, "")
|
config: str = AuthenticationService.ENDPOINT_CACHE[authentication_identifier].get(name, "")
|
||||||
return config
|
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
|
@staticmethod
|
||||||
def get_backend_url() -> str:
|
def get_backend_url() -> str:
|
||||||
return str(current_app.config["SPIFFWORKFLOW_BACKEND_URL"])
|
return str(current_app.config["SPIFFWORKFLOW_BACKEND_URL"])
|
||||||
|
@ -177,7 +251,7 @@ class AuthenticationService:
|
||||||
if azp is None:
|
if azp is None:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
valid_client_ids = [cls.client_id(authentication_identifier), "account"]
|
valid_client_ids = [cls.client_id(authentication_identifier)]
|
||||||
if (
|
if (
|
||||||
"additional_valid_client_ids" in cls.authentication_option_for_identifier(authentication_identifier)
|
"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
|
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
|
return azp in valid_client_ids
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation."""
|
||||||
valid = True
|
valid = True
|
||||||
now = round(time.time())
|
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
|
# give a 5 second leeway to iat in case keycloak server time doesn't match backend server
|
||||||
iat_clock_skew_leeway = 5
|
iat_clock_skew_leeway = 5
|
||||||
|
|
||||||
iss = decoded_token["iss"]
|
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
|
azp = decoded_token["azp"] if "azp" in decoded_token else None
|
||||||
iat = decoded_token["iat"]
|
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
|
audience_array_in_token = aud
|
||||||
if isinstance(aud, str):
|
if isinstance(aud, str):
|
||||||
audience_array_in_token = [aud]
|
audience_array_in_token = [aud]
|
||||||
overlapping_aud_values = [x for x in audience_array_in_token if x in valid_audience_values]
|
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(
|
current_app.logger.error(
|
||||||
f"TOKEN INVALID because ISS '{iss}' does not match server url '{cls.server_url(authentication_identifier)}'"
|
f"TOKEN INVALID because ISS '{iss}' does not match server url '{cls.server_url(authentication_identifier)}'"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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_assignment import PermissionAssignmentModel
|
||||||
from spiffworkflow_backend.models.permission_target import PermissionTargetModel
|
from spiffworkflow_backend.models.permission_target import PermissionTargetModel
|
||||||
from spiffworkflow_backend.models.principal import PrincipalModel
|
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.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE
|
||||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||||
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
|
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
|
||||||
|
@ -281,7 +282,7 @@ class AuthorizationService:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_for_permission(cls) -> None:
|
def check_for_permission(cls, decoded_token: dict | None) -> None:
|
||||||
if cls.should_disable_auth_for_request():
|
if cls.should_disable_auth_for_request():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -293,7 +294,7 @@ class AuthorizationService:
|
||||||
if cls.request_is_excluded_from_permission_check():
|
if cls.request_is_excluded_from_permission_check():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if cls.request_allows_guest_access():
|
if cls.request_allows_guest_access(decoded_token):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
permission_string = cls.get_permission_from_http_method(request.method)
|
permission_string = cls.get_permission_from_http_method(request.method)
|
||||||
|
@ -319,7 +320,7 @@ class AuthorizationService:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@classmethod
|
@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():
|
if cls.request_is_excluded_from_permission_check():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -329,6 +330,18 @@ class AuthorizationService:
|
||||||
task_guid = request.path.split("/")[4]
|
task_guid = request.path.split("/")[4]
|
||||||
if TaskModel.task_guid_allows_guest(task_guid, process_instance_id):
|
if TaskModel.task_guid_allows_guest(task_guid, process_instance_id):
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import ast
|
import ast
|
||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
|
|
||||||
from flask.app import Flask
|
from flask.app import Flask
|
||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
|
@ -45,8 +44,6 @@ class TestAuthentication(BaseTest):
|
||||||
"groups": ["group_one", "group_two"],
|
"groups": ["group_one", "group_two"],
|
||||||
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
|
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
|
||||||
"aud": "spiffworkflow-backend",
|
"aud": "spiffworkflow-backend",
|
||||||
"iat": round(time.time()),
|
|
||||||
"exp": round(time.time()) + 1000,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
response = None
|
response = None
|
||||||
|
@ -63,8 +60,6 @@ class TestAuthentication(BaseTest):
|
||||||
"groups": ["group_one"],
|
"groups": ["group_one"],
|
||||||
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
|
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
|
||||||
"aud": "spiffworkflow-backend",
|
"aud": "spiffworkflow-backend",
|
||||||
"iat": round(time.time()),
|
|
||||||
"exp": round(time.time()) + 1000,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
|
|
Loading…
Reference in New Issue