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:
jasquat 2024-01-16 14:47:25 -05:00 committed by GitHub
parent 2d9fb4430e
commit 80bc2f2e42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 313 additions and 173 deletions

View File

@ -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
} }

View File

@ -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"

View File

@ -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}"
@ -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 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 fi
# this line blocks # this line blocks
poetry run flask run -p "$port" --host=0.0.0.0 poetry run flask run -p "$port" --host=0.0.0.0
fi fi
fi fi

View File

@ -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

View File

@ -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

View File

@ -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,7 +71,11 @@ 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)
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. # 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 # 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) 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

View File

@ -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
@ -2222,15 +2220,14 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Task" $ref: "#/components/schemas/Task"
responses: responses:
"200": "200":
description: Returns the same data structure provided, but after some replacements. description: Returns the same data structure provided, but after some replacements.
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Task" $ref: "#/components/schemas/Task"
/tasks/{process_instance_id}/instruction: /tasks/{process_instance_id}/instruction:
parameters: parameters:
@ -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

View File

@ -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",

View File

@ -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")

View File

@ -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-----"""

View File

@ -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

View File

@ -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

View File

@ -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]:

View File

@ -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,88 +333,87 @@ 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:
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(): if user_info is None:
user_info = None AuthenticationService.set_user_has_logged_out()
authentication_identifier = _get_authentication_identifier_from_request() raise ApiError(
try: error_code="invalid_token",
if AuthenticationService.validate_id_or_access_token(token, authentication_identifier=authentication_identifier): message="Your token is expired. Please Login",
user_info = decoded_token status_code=401,
except TokenExpiredError as token_expired_error: ) from 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: except Exception as e:
AuthenticationService.set_user_has_logged_out() AuthenticationService.set_user_has_logged_out()
raise ApiError( raise ApiError(
error_code="invalid_token", error_code="fail_get_user_info",
message="Your token is expired. Please Login", message="Cannot get user info from token",
status_code=401, status_code=401,
) from token_expired_error ) 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
except Exception as e: user_model = (
AuthenticationService.set_user_has_logged_out() UserModel.query.filter(UserModel.service == user_info["iss"])
raise ApiError( .filter(UserModel.service_id == user_info["sub"])
error_code="fail_get_user_info", .first()
message="Cannot get user info from token", )
status_code=401, if user_model is None:
) from e AuthenticationService.set_user_has_logged_out()
if user_info is not None and "error" not in user_info and "iss" in user_info: # not sure what to test yet raise ApiError(
user_model = ( error_code="invalid_user",
UserModel.query.filter(UserModel.service == user_info["iss"]) message="Invalid user. Please log in.",
.filter(UserModel.service_id == user_info["sub"]) status_code=401,
.first() )
) # no user_info
if user_model is None: else:
AuthenticationService.set_user_has_logged_out() AuthenticationService.set_user_has_logged_out()
raise ApiError( raise ApiError(
error_code="invalid_user", error_code="no_user_info",
message="Invalid user. Please log in.", message="Cannot retrieve user info",
status_code=401, 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: 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"]

View File

@ -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 ""

View File

@ -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)}'"
) )

View File

@ -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

View File

@ -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(