From d5b0330609f3132acdd681889730fe15703dad2c Mon Sep 17 00:00:00 2001 From: jasquat <2487833+jasquat@users.noreply.github.com> Date: Thu, 9 Nov 2023 10:34:07 -0500 Subject: [PATCH] Feature/support multiple auth (#602) * added some support for configs to have mutliple auths * multiple openids services are mostly working - still needs some cleanup * some cleanup for pyl and fixed login_return for internal openid server w/ burnettk * if only one auth is returned from backend then just do that w/ burnettk * login page has been formatted w/ burnettk * some extra formatting on the login page w/ burnettk * relabel test openid providers and add user --------- Co-authored-by: jasquat Co-authored-by: burnettk --- .../bin/local_development_environment_setup | 18 +- .../src/spiffworkflow_backend/api.yml | 30 ++++ .../spiffworkflow_backend/config/__init__.py | 11 ++ .../spiffworkflow_backend/config/default.py | 32 ++-- .../config/normalized_environment.py | 108 ++++++++++++ .../config/permissions/local_development.yml | 13 +- .../routes/authentication_controller.py | 63 +++++-- .../routes/debug_controller.py | 3 +- .../services/authentication_service.py | 161 +++++++++++++----- .../services/authorization_service.py | 1 + .../integration/test_authentication.py | 12 +- .../integration/test_tasks_controller.py | 2 +- .../src/ContainerForExtensions.tsx | 10 +- .../src/components/LoginHandler.tsx | 13 ++ .../src/components/NavigationBar.tsx | 32 ++-- .../src/components/ProcessInstanceRun.tsx | 5 +- spiffworkflow-frontend/src/index.css | 16 ++ spiffworkflow-frontend/src/interfaces.ts | 6 + .../src/routes/BaseRoutes.tsx | 2 + .../src/routes/EditorRoutes.tsx | 2 + .../src/routes/Extension.tsx | 2 + spiffworkflow-frontend/src/routes/Login.tsx | 76 +++++++++ .../src/services/HttpService.ts | 3 +- .../src/services/UserService.ts | 34 ++-- 24 files changed, 537 insertions(+), 118 deletions(-) create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/config/normalized_environment.py create mode 100644 spiffworkflow-frontend/src/components/LoginHandler.tsx create mode 100644 spiffworkflow-frontend/src/routes/Login.tsx diff --git a/spiffworkflow-backend/bin/local_development_environment_setup b/spiffworkflow-backend/bin/local_development_environment_setup index 72466211..138ed4e3 100755 --- a/spiffworkflow-backend/bin/local_development_environment_setup +++ b/spiffworkflow-backend/bin/local_development_environment_setup @@ -29,8 +29,24 @@ if [[ "$process_model_dir" == "acceptance" ]]; then export SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=true export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=acceptance_tests.yml elif [[ "$process_model_dir" == "localopenid" ]]; then - export SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL="http://localhost:$port/openid" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="openid" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="openid" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__uri="http://localhost:$port/openid" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_id="spiffworkflow-backend" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q" export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME="example.yml" +else + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="keycloak_internal" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="I am a Core Contributor" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__uri="http://localhost:7002/realms/spiffworkflow" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_id="spiffworkflow-backend" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q" + + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__identifier="openid" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__label="I am a vendor" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__uri="http://localhost:$port/openid" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__client_id="spiffworkflow-backend" + export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q" fi if [[ -z "${SPIFFWORKFLOW_BACKEND_ENV:-}" ]]; then diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 46a7cd0d..045f451c 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -10,8 +10,22 @@ servers: security: [] paths: + /authentication-options: + get: + summary: redirect to open id authentication server + operationId: spiffworkflow_backend.routes.authentication_controller.authentication_options + tags: + - Authentication + responses: + "200": + description: Redirects to authentication server /login: parameters: + - name: authentication_identifier + in: query + required: true + schema: + type: string - name: redirect_url in: query required: false @@ -71,6 +85,11 @@ paths: required: false schema: type: string + - name: authentication_identifier + in: query + required: true + schema: + type: string get: operationId: spiffworkflow_backend.routes.authentication_controller.logout summary: Logout authenticated user @@ -96,6 +115,11 @@ paths: required: true schema: type: string + - name: authentication_identifier + in: query + required: true + schema: + type: string post: operationId: spiffworkflow_backend.routes.authentication_controller.login_with_access_token summary: Authenticate user for API access with an openid token already posessed. @@ -110,6 +134,12 @@ paths: $ref: "#/components/schemas/OkTrue" /login_api: + parameters: + - name: authentication_identifier + in: query + required: true + schema: + type: string get: operationId: spiffworkflow_backend.routes.authentication_controller.login_api summary: Authenticate user for API access diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py index 80db070e..f9a3ee30 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py @@ -195,6 +195,17 @@ def setup_config(app: Flask) -> None: app.config["SPIFFWORKFLOW_BACKEND_MAX_INSTANCE_LOCK_DURATION_IN_SECONDS"] ) + if "SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS" not in app.config: + app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"] = [ + { + "identifier": "default", + "label": "Default", + "uri": app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"), + "client_id": app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID"), + "client_secret": app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY"), + } + ] + thread_local_data = threading.local() app.config["THREAD_LOCAL_DATA"] = thread_local_data _set_up_tenant_specific_fields_as_list_of_strings(app) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index eaee9c53..70349f71 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -1,6 +1,8 @@ import re from os import environ +from spiffworkflow_backend.config.normalized_environment import normalized_environment + # Consider: https://flask.palletsprojects.com/en/2.2.x/config/#configuring-from-environment-variables # and from_prefixed_env(), though we want to ensure that these variables are all documented, so that # is a benefit of the status quo and having them all in this file explicitly. @@ -30,6 +32,8 @@ def config_from_env(variable_name: str, *, default: str | bool | int | None = No globals()[variable_name] = value_to_return +configs_with_structures = normalized_environment(environ) + ### basic config_from_env("FLASK_SESSION_SECRET_KEY") config_from_env("SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR") @@ -75,24 +79,28 @@ config_from_env("SPIFFWORKFLOW_BACKEND_DATABASE_POOL_SIZE") ### open id config_from_env("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED", default=False) -# Open ID server -# use "http://localhost:7000/openid" for running with simple openid -# server hosted by spiffworkflow-backend -config_from_env( - "SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL", - default="http://localhost:7002/realms/spiffworkflow", -) -config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID", default="spiffworkflow-backend") -config_from_env( - "SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY", - default="JXeQExm0JhQPLumgHtIIqf52bDalHz0q", -) # noqa: S105 config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_IS_AUTHORITY_FOR_USER_GROUPS", default=False) # Tenant specific fields is a comma separated list of field names that we will be converted to list of strings # and store in the user table's tenant_specific_field_n columns. You can have up to three items in this # comma-separated list. config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_TENANT_SPECIFIC_FIELDS") +# Open ID server +# use "http://localhost:7000/openid" for running with simple openid +# server hosted by spiffworkflow-backend +if "SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS" in configs_with_structures: + SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS = configs_with_structures["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"] +else: + SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS = [ + { + "identifier": "default", + "label": "Default", + "uri": "http://localhost:7002/realms/spiffworkflow", + "client_id": "spiffworkflow-backend", + "client_secret": "JXeQExm0JhQPLumgHtIIqf52bDalHz0q", + } + ] + ### logs # loggers to use is a comma separated list of logger prefixes that we will be converted to list of strings config_from_env("SPIFFWORKFLOW_BACKEND_LOGGERS_TO_USE") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/normalized_environment.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/normalized_environment.py new file mode 100644 index 00000000..6632c710 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/normalized_environment.py @@ -0,0 +1,108 @@ +import itertools +import os +from collections.abc import ItemsView +from collections.abc import Iterable + + +def normalized_environment(key_values: os._Environ) -> dict: + results = _parse_environment(key_values) + if isinstance(results, dict): + return results + raise Exception( + f"results from parsing environment variables was not a dict. This is troubling. Results were: {results}" + ) + + +# source originally from: https://charemza.name/blog/posts/software-engineering/devops/structured-data-in-environment-variables/ +def _parse_environment(key_values: os._Environ | dict) -> list | dict: + """Converts denormalised dict of (string -> string) pairs, where the first string + is treated as a path into a nested list/dictionary structure + + { + "FOO__1__BAR": "setting-1", + "FOO__1__BAZ": "setting-2", + "FOO__2__FOO": "setting-3", + "FOO__2__BAR": "setting-4", + "FIZZ": "setting-5", + } + + to the nested structure that this represents + + { + "FOO": [{ + "BAR": "setting-1", + "BAZ": "setting-2", + }, { + "FOO": "setting-3", + "BAR": "setting-4", + }], + "FIZZ": "setting-5", + } + + If all the keys for that level parse as integers, then it's treated as a list + with the actual keys only used for sorting + + This function is recursive, but it would be extremely difficult to hit a stack + limit, and this function would typically by called once at the start of a + program, so efficiency isn't too much of a concern. + + Copyright (c) 2018 Department for International Trade. All rights reserved. + + This work is licensed under the terms of the MIT license. + For a copy, see https://opensource.org/licenses/MIT. + """ + + # Separator is chosen to + # - show the structure of variables fairly easily; + # - avoid problems, since underscores are usual in environment variables + separator = "__" + + def get_first_component(key: str) -> str: + return key.split(separator)[0] + + def get_later_components(key: str) -> str: + return separator.join(key.split(separator)[1:]) + + without_more_components = {key: value for key, value in key_values.items() if not get_later_components(key)} + + with_more_components = {key: value for key, value in key_values.items() if get_later_components(key)} + + def grouped_by_first_component(items: ItemsView[str, str]) -> Iterable: + def by_first_component(item: tuple) -> str: + return get_first_component(item[0]) + + # groupby requires the items to be sorted by the grouping key + return itertools.groupby( + sorted(items, key=by_first_component), + by_first_component, + ) + + def items_with_first_component(items: ItemsView, first_component: str) -> dict: + return { + get_later_components(key): value for key, value in items if get_first_component(key) == first_component + } + + nested_structured_dict = { + **without_more_components, + **{ + first_component: _parse_environment(items_with_first_component(items, first_component)) + for first_component, items in grouped_by_first_component(with_more_components.items()) + }, + } + + def all_keys_are_ints() -> bool: + def is_int(string: str) -> bool: + try: + int(string) + return True + except ValueError: + return False + + return all(is_int(key) for key, value in nested_structured_dict.items()) + + def list_sorted_by_int_key() -> list: + return [ + value for key, value in sorted(nested_structured_dict.items(), key=lambda key_value: int(key_value[0])) + ] + + return list_sorted_by_int_key() if all_keys_are_ints() else nested_structured_dict diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml index 9ea0c22e..42bdcc8c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml @@ -1,7 +1,18 @@ +users: + admin: + service: local_open_id + email: admin@spiffworkflow.org + password: admin + preferred_username: Admin + nelson: + service: local_open_id + email: nelson@spiffworkflow.org + password: nelson + preferred_username: Nelson groups: admin: - users: [admin@spiffworkflow.org] + users: [admin@spiffworkflow.org, nelson@spiffworkflow.org] group1: users: [jason@sartography.com, kevin@sartography.com] group2: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py index 0037ea34..34a075f0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py @@ -35,6 +35,11 @@ from spiffworkflow_backend.services.user_service import UserService """ +def authentication_options() -> Response: + response = AuthenticationService.authentication_options_for_api() + return make_response(jsonify(response), 200) + + # authorization_exclusion_list = ['status'] def verify_token(token: str | None = None, force_run: bool | None = False) -> None: """Verify the token for the user (if provided). @@ -88,13 +93,19 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No raise ApiError(error_code="invalid_token", message="Cannot validate token.", status_code=401) -def login(redirect_url: str = "/", process_instance_id: int | None = None, task_guid: str | None = None) -> Response: +def login( + authentication_identifier: str, + redirect_url: str = "/", + process_instance_id: int | None = None, + task_guid: str | None = None, +) -> Response: if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"): AuthenticationService.create_guest_token( username=SPIFF_NO_AUTH_USER, group_identifier=SPIFF_NO_AUTH_GROUP, permission_target="/*", auth_token_properties={"authentication_disabled": True}, + authentication_identifier=authentication_identifier, ) return redirect(redirect_url) @@ -103,23 +114,31 @@ def login(redirect_url: str = "/", process_instance_id: int | None = None, task_ username=SPIFF_GUEST_USER, group_identifier=SPIFF_GUEST_GROUP, auth_token_properties={"only_guest_task_completion": True}, + authentication_identifier=authentication_identifier, ) return redirect(redirect_url) - state = AuthenticationService.generate_state(redirect_url) - login_redirect_url = AuthenticationService().get_login_redirect_url(state.decode("UTF-8")) + state = AuthenticationService.generate_state(redirect_url, authentication_identifier) + login_redirect_url = AuthenticationService().get_login_redirect_url( + state.decode("UTF-8"), authentication_identifier=authentication_identifier + ) return redirect(login_redirect_url) def login_return(code: str, state: str, session_state: str = "") -> Response | None: state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8")) state_redirect_url = state_dict["redirect_url"] - auth_token_object = AuthenticationService().get_auth_token_object(code) + authentication_identifier = state_dict["authentication_identifier"] + auth_token_object = AuthenticationService().get_auth_token_object( + code, authentication_identifier=authentication_identifier + ) if "id_token" in auth_token_object: id_token = auth_token_object["id_token"] user_info = _parse_id_token(id_token) - if AuthenticationService.validate_id_or_access_token(id_token): + if AuthenticationService.validate_id_or_access_token( + id_token, authentication_identifier=authentication_identifier + ): if user_info and "error" not in user_info: user_model = AuthorizationService.create_user_from_sign_in(user_info) g.user = user_model.id @@ -130,6 +149,7 @@ def login_return(code: str, state: str, session_state: str = "") -> Response | N tld = current_app.config["THREAD_LOCAL_DATA"] tld.new_access_token = auth_token_object["id_token"] tld.new_id_token = auth_token_object["id_token"] + tld.new_authentication_identifier = authentication_identifier return redirect(redirect_url) raise ApiError( @@ -148,10 +168,12 @@ def login_return(code: str, state: str, session_state: str = "") -> Response | N # FIXME: share more code with login_return and maybe attempt to get a refresh token -def login_with_access_token(access_token: str) -> Response: +def login_with_access_token(access_token: str, authentication_identifier: str) -> Response: user_info = _parse_id_token(access_token) - if AuthenticationService.validate_id_or_access_token(access_token): + if AuthenticationService.validate_id_or_access_token( + access_token, authentication_identifier=authentication_identifier + ): if user_info and "error" not in user_info: AuthorizationService.create_user_from_sign_in(user_info) else: @@ -164,9 +186,9 @@ def login_with_access_token(access_token: str) -> Response: return make_response(jsonify({"ok": True})) -def login_api() -> Response: +def login_api(authentication_identifier: str) -> Response: redirect_url = "/v1.0/login_api_return" - state = AuthenticationService.generate_state(redirect_url) + state = AuthenticationService.generate_state(redirect_url, authentication_identifier) login_redirect_url = AuthenticationService().get_login_redirect_url(state.decode("UTF-8"), redirect_url) return redirect(login_redirect_url) @@ -183,12 +205,14 @@ def login_api_return(code: str, state: str, session_state: str) -> str: return access_token -def logout(id_token: str, redirect_url: str | None) -> Response: +def logout(id_token: str, authentication_identifier: str, redirect_url: str | None) -> Response: if redirect_url is None: redirect_url = "" tld = current_app.config["THREAD_LOCAL_DATA"] tld.user_has_logged_out = True - return AuthenticationService().logout(redirect_url=redirect_url, id_token=id_token) + return AuthenticationService().logout( + redirect_url=redirect_url, id_token=id_token, authentication_identifier=authentication_identifier + ) def logout_return() -> Response: @@ -229,9 +253,15 @@ def _set_new_access_token_in_cookie( if hasattr(tld, "new_id_token") and tld.new_id_token: response.set_cookie("id_token", tld.new_id_token, domain=domain_for_frontend_cookie) + if hasattr(tld, "new_authentication_identifier") and tld.new_authentication_identifier: + response.set_cookie( + "authentication_identifier", tld.new_authentication_identifier, domain=domain_for_frontend_cookie + ) + if hasattr(tld, "user_has_logged_out") and tld.user_has_logged_out: response.set_cookie("id_token", "", max_age=0, domain=domain_for_frontend_cookie) response.set_cookie("access_token", "", max_age=0, domain=domain_for_frontend_cookie) + response.set_cookie("authentication_identifier", "", max_age=0, domain=domain_for_frontend_cookie) _clear_auth_tokens_from_thread_local_data() @@ -244,6 +274,8 @@ def _clear_auth_tokens_from_thread_local_data() -> None: delattr(tld, "new_access_token") if hasattr(tld, "new_id_token"): delattr(tld, "new_id_token") + if hasattr(tld, "new_authentication_identifier"): + delattr(tld, "new_authentication_identifier") if hasattr(tld, "user_has_logged_out"): delattr(tld, "user_has_logged_out") @@ -318,8 +350,11 @@ def _get_user_model_from_token(token: str) -> UserModel | None: elif "iss" in decoded_token.keys(): user_info = None + authentication_identifier = request.headers["authentication_identifier"] try: - if AuthenticationService.validate_id_or_access_token(token): + if AuthenticationService.validate_id_or_access_token( + token, authentication_identifier=authentication_identifier + ): user_info = decoded_token except TokenExpiredError as token_expired_error: # Try to refresh the token @@ -327,7 +362,9 @@ def _get_user_model_from_token(token: str) -> UserModel | None: 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) + 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"] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/debug_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/debug_controller.py index 98544068..b46a0eef 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/debug_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/debug_controller.py @@ -4,6 +4,7 @@ from flask import request from flask.wrappers import Response from spiffworkflow_backend import get_version_info_data +from spiffworkflow_backend.services.authentication_service import AuthenticationService def test_raise_error() -> Response: @@ -15,4 +16,4 @@ def version_info() -> Response: def url_info() -> Response: - return make_response({"url": request.url}, 200) + return make_response({"url": request.url, "cache": AuthenticationService.ENDPOINT_CACHE}, 200) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py index 0e750e80..7be9ced7 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py @@ -5,6 +5,7 @@ import time from hashlib import sha256 from hmac import HMAC from hmac import compare_digest +from typing import TypedDict import jwt import requests @@ -30,51 +31,94 @@ class AuthenticationProviderTypes(enum.Enum): internal = "internal" +class AuthenticationOptionForApi(TypedDict): + identifier: str + label: str + uri: str + + +class AuthenticationOption(AuthenticationOptionForApi): + client_id: str + client_secret: str + + +class AuthenticationOptionNotFoundError(Exception): + pass + + class AuthenticationService: - ENDPOINT_CACHE: dict = {} # 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. - @staticmethod - def client_id() -> str: + @classmethod + def authentication_options_for_api(cls) -> list[AuthenticationOptionForApi]: + # ensure we remove sensitive info such as client secret from the config before sending it back + configs: list[AuthenticationOptionForApi] = [] + for config in current_app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"]: + configs.append( + { + "identifier": config["identifier"], + "label": config["label"], + "uri": config["uri"], + } + ) + return configs + + @classmethod + def authentication_option_for_identifier(cls, authentication_identifier: str) -> AuthenticationOption: + for config in current_app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"]: + if config["identifier"] == authentication_identifier: + return_config: AuthenticationOption = config + return return_config + raise AuthenticationOptionNotFoundError( + f"Could not find a config with identifier '{authentication_identifier}'" + ) + + @classmethod + def client_id(cls, authentication_identifier: str) -> str: """Returns the client id from the config.""" - config: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID", "") - return config - - @staticmethod - def server_url() -> str: - """Returns the server url from the config.""" - config: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL", "") - return config - - @staticmethod - def secret_key() -> str: - """Returns the secret key from the config.""" - config: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY", "") + config: str = cls.authentication_option_for_identifier(authentication_identifier)["client_id"] return config @classmethod - def open_id_endpoint_for_name(cls, name: str) -> str: + def server_url(cls, authentication_identifier: str) -> str: + """Returns the server url from the config.""" + config: str = cls.authentication_option_for_identifier(authentication_identifier)["uri"] + return config + + @classmethod + def secret_key(cls, authentication_identifier: str) -> str: + """Returns the secret key from the config.""" + config: str = cls.authentication_option_for_identifier(authentication_identifier)["client_secret"] + return config + + @classmethod + def open_id_endpoint_for_name(cls, name: str, authentication_identifier: str) -> str: """All openid systems provide a mapping of static names to the full path of that endpoint.""" - openid_config_url = f"{cls.server_url()}/.well-known/openid-configuration" - if name not in AuthenticationService.ENDPOINT_CACHE: + openid_config_url = f"{cls.server_url(authentication_identifier)}/.well-known/openid-configuration" + if authentication_identifier not in cls.ENDPOINT_CACHE: + cls.ENDPOINT_CACHE[authentication_identifier] = {} + if name not in AuthenticationService.ENDPOINT_CACHE[authentication_identifier]: try: response = requests.get(openid_config_url, timeout=HTTP_REQUEST_TIMEOUT_SECONDS) - AuthenticationService.ENDPOINT_CACHE = response.json() + AuthenticationService.ENDPOINT_CACHE[authentication_identifier] = response.json() except requests.exceptions.ConnectionError as ce: raise OpenIdConnectionError(f"Cannot connect to given open id url: {openid_config_url}") from ce - if name not in AuthenticationService.ENDPOINT_CACHE: + if name not in AuthenticationService.ENDPOINT_CACHE[authentication_identifier]: raise Exception(f"Unknown OpenID Endpoint: {name}. Tried to get from {openid_config_url}") - config: str = AuthenticationService.ENDPOINT_CACHE.get(name, "") + config: str = AuthenticationService.ENDPOINT_CACHE[authentication_identifier].get(name, "") return config @staticmethod def get_backend_url() -> str: return str(current_app.config["SPIFFWORKFLOW_BACKEND_URL"]) - def logout(self, id_token: str, redirect_url: str | None = None) -> Response: + def logout(self, id_token: str, authentication_identifier: str, redirect_url: str | None = None) -> Response: if redirect_url is None: redirect_url = f"{self.get_backend_url()}/v1.0/logout_return" request_url = ( - self.open_id_endpoint_for_name("end_session_endpoint") + self.open_id_endpoint_for_name("end_session_endpoint", authentication_identifier=authentication_identifier) + f"?post_logout_redirect_uri={redirect_url}&" + f"id_token_hint={id_token}" ) @@ -82,24 +126,35 @@ class AuthenticationService: return redirect(request_url) @staticmethod - def generate_state(redirect_url: str) -> bytes: - state = base64.b64encode(bytes(str({"redirect_url": redirect_url}), "UTF-8")) + def generate_state(redirect_url: str, authentication_identifier: str) -> bytes: + state = base64.b64encode( + bytes(str({"redirect_url": redirect_url, "authentication_identifier": authentication_identifier}), "UTF-8") + ) return state - def get_login_redirect_url(self, state: str, redirect_url: str = "/v1.0/login_return") -> str: + def get_login_redirect_url( + self, state: str, authentication_identifier: str, redirect_url: str = "/v1.0/login_return" + ) -> str: return_redirect_url = f"{self.get_backend_url()}{redirect_url}" login_redirect_url = ( - self.open_id_endpoint_for_name("authorization_endpoint") + self.open_id_endpoint_for_name( + "authorization_endpoint", authentication_identifier=authentication_identifier + ) + f"?state={state}&" + "response_type=code&" - + f"client_id={self.client_id()}&" + + f"client_id={self.client_id(authentication_identifier)}&" + "scope=openid profile email&" + f"redirect_uri={return_redirect_url}" ) + print(f"login_redirect_url: {login_redirect_url}") return login_redirect_url - def get_auth_token_object(self, code: str, redirect_url: str = "/v1.0/login_return") -> dict: - backend_basic_auth_string = f"{self.client_id()}:{self.secret_key()}" + def get_auth_token_object( + self, code: str, authentication_identifier: str, redirect_url: str = "/v1.0/login_return" + ) -> dict: + backend_basic_auth_string = ( + f"{self.client_id(authentication_identifier)}:{self.secret_key(authentication_identifier)}" + ) backend_basic_auth_bytes = bytes(backend_basic_auth_string, encoding="ascii") backend_basic_auth = base64.b64encode(backend_basic_auth_bytes) headers = { @@ -112,14 +167,17 @@ class AuthenticationService: "redirect_uri": f"{self.get_backend_url()}{redirect_url}", } - request_url = self.open_id_endpoint_for_name("token_endpoint") + request_url = self.open_id_endpoint_for_name( + "token_endpoint", authentication_identifier=authentication_identifier + ) response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS) auth_token_object: dict = json.loads(response.text) + print(f"auth_token_object: {auth_token_object}") return auth_token_object @classmethod - def validate_id_or_access_token(cls, token: str) -> bool: + def validate_id_or_access_token(cls, token: str, authentication_identifier: str) -> bool: """Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation.""" valid = True now = round(time.time()) @@ -136,28 +194,33 @@ class AuthenticationService: azp = decoded_token["azp"] if "azp" in decoded_token else None iat = decoded_token["iat"] - valid_audience_values = (cls.client_id(), "account") + valid_audience_values = (cls.client_id(authentication_identifier), "account") audience_array_in_token = aud if isinstance(aud, str): audience_array_in_token = [aud] overlapping_aud_values = [x for x in audience_array_in_token if x in valid_audience_values] - if iss != cls.server_url(): + if iss != cls.server_url(authentication_identifier): current_app.logger.error( - f"TOKEN INVALID because ISS '{iss}' does not match server url '{cls.server_url()}'" + f"TOKEN INVALID because ISS '{iss}' does not match server url" + f" '{cls.server_url(authentication_identifier)}'" ) valid = False # aud could be an array or a string elif len(overlapping_aud_values) < 1: current_app.logger.error( - f"TOKEN INVALID because audience '{aud}' does not match client id '{cls.client_id()}'" + f"TOKEN INVALID because audience '{aud}' does not match client id" + f" '{cls.client_id(authentication_identifier)}'" ) valid = False elif azp and azp not in ( - cls.client_id(), + cls.client_id(authentication_identifier), "account", ): - current_app.logger.error(f"TOKEN INVALID because azp '{azp}' does not match client id '{cls.client_id()}'") + current_app.logger.error( + f"TOKEN INVALID because azp '{azp}' does not match client id" + f" '{cls.client_id(authentication_identifier)}'" + ) valid = False # make sure issued at time is not in the future elif now + iat_clock_skew_leeway < iat: @@ -175,8 +238,8 @@ class AuthenticationService: f"AUD: {aud} " f"AZP: {azp} " f"IAT: {iat} " - f"SERVER_URL: {cls.server_url()} " - f"CLIENT_ID: {cls.client_id()} " + f"SERVER_URL: {cls.server_url(authentication_identifier)} " + f"CLIENT_ID: {cls.client_id(authentication_identifier)} " f"NOW: {now}" ) @@ -208,9 +271,11 @@ class AuthenticationService: return None @classmethod - def get_auth_token_from_refresh_token(cls, refresh_token: str) -> dict: + def get_auth_token_from_refresh_token(cls, refresh_token: str, authentication_identifier: str) -> dict: """Converts a refresh token to an Auth Token by calling the openid's auth endpoint.""" - backend_basic_auth_string = f"{cls.client_id()}:{cls.secret_key()}" + backend_basic_auth_string = ( + f"{cls.client_id(authentication_identifier)}:{cls.secret_key(authentication_identifier)}" + ) backend_basic_auth_bytes = bytes(backend_basic_auth_string, encoding="ascii") backend_basic_auth = base64.b64encode(backend_basic_auth_bytes) headers = { @@ -221,11 +286,13 @@ class AuthenticationService: data = { "grant_type": "refresh_token", "refresh_token": refresh_token, - "client_id": cls.client_id(), - "client_secret": cls.secret_key(), + "client_id": cls.client_id(authentication_identifier), + "client_secret": cls.secret_key(authentication_identifier), } - request_url = cls.open_id_endpoint_for_name("token_endpoint") + request_url = cls.open_id_endpoint_for_name( + "token_endpoint", authentication_identifier=authentication_identifier + ) response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS) auth_token_object: dict = json.loads(response.text) @@ -270,6 +337,7 @@ class AuthenticationService: cls, username: str, group_identifier: str, + authentication_identifier: str, permission_target: str | None = None, permission: str = "all", auth_token_properties: dict | None = None, @@ -284,3 +352,4 @@ class AuthenticationService: tld = current_app.config["THREAD_LOCAL_DATA"] tld.new_access_token = g.token tld.new_id_token = g.token + tld.new_authentication_identifier = authentication_identifier diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index b0cdcf82..26917c85 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -271,6 +271,7 @@ class AuthorizationService: api_view_function and api_view_function.__name__.startswith("login") or api_view_function.__name__.startswith("logout") + or api_view_function.__name__.startswith("authentication_options") or api_view_function.__name__.startswith("prom") or api_view_function.__name__ == "url_info" or api_view_function.__name__.startswith("metric") diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py index 97dfcc4e..46719c58 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py @@ -18,7 +18,7 @@ from tests.spiffworkflow_backend.helpers.base_test import BaseTest class TestAuthentication(BaseTest): def test_get_login_state(self) -> None: redirect_url = "http://example.com/" - state = AuthenticationService.generate_state(redirect_url) + state = AuthenticationService.generate_state(redirect_url, authentication_identifier="default") state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8")) assert isinstance(state_dict, dict) @@ -34,14 +34,14 @@ class TestAuthentication(BaseTest): with self.app_config_mock(app, "SPIFFWORKFLOW_BACKEND_OPEN_ID_IS_AUTHORITY_FOR_USER_GROUPS", True): user = self.find_or_create_user("testing@e.com") user.email = "testing@e.com" - user.service = app.config["SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"] + user.service = app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"] db.session.add(user) db.session.commit() access_token = user.encode_auth_token( { "groups": ["group_one", "group_two"], - "iss": app.config["SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"], + "iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"], "aud": "spiffworkflow-backend", "iat": round(time.time()), "exp": round(time.time()) + 1000, @@ -49,7 +49,7 @@ class TestAuthentication(BaseTest): ) response = None response = client.post( - f"/v1.0/login_with_access_token?access_token={access_token}", + f"/v1.0/login_with_access_token?access_token={access_token}&authentication_identifier=default", ) assert response.status_code == 200 assert len(user.groups) == 3 @@ -59,14 +59,14 @@ class TestAuthentication(BaseTest): access_token = user.encode_auth_token( { "groups": ["group_one"], - "iss": app.config["SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"], + "iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"], "aud": "spiffworkflow-backend", "iat": round(time.time()), "exp": round(time.time()) + 1000, } ) response = client.post( - f"/v1.0/login_with_access_token?access_token={access_token}", + f"/v1.0/login_with_access_token?access_token={access_token}&authentication_identifier=default", ) assert response.status_code == 200 user = UserModel.query.filter_by(username=user.username).first() diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py index b4da2007..72081415 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py @@ -489,7 +489,7 @@ class TestTasksController(BaseTest): # log in a guest user to complete the tasks redirect_url = "/test-redirect-dne" response = client.get( - f"/v1.0/login?process_instance_id={process_instance_id}&task_guid={task_guid}&redirect_url={redirect_url}", + f"/v1.0/login?process_instance_id={process_instance_id}&task_guid={task_guid}&redirect_url={redirect_url}&authentication_identifier=DOES_NOT_MATTER", ) assert response.status_code == 302 assert response.location == redirect_url diff --git a/spiffworkflow-frontend/src/ContainerForExtensions.tsx b/spiffworkflow-frontend/src/ContainerForExtensions.tsx index 61277d67..8976f211 100644 --- a/spiffworkflow-frontend/src/ContainerForExtensions.tsx +++ b/spiffworkflow-frontend/src/ContainerForExtensions.tsx @@ -2,7 +2,6 @@ import { Content } from '@carbon/react'; import { Routes, Route } from 'react-router-dom'; import React, { useEffect, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import NavigationBar from './components/NavigationBar'; import ScrollToTop from './components/ScrollToTop'; import EditorRoutes from './routes/EditorRoutes'; @@ -18,7 +17,8 @@ import HttpService from './services/HttpService'; import { ErrorBoundaryFallback } from './ErrorBoundaryFallack'; import BaseRoutes from './routes/BaseRoutes'; import BackendIsDown from './routes/BackendIsDown'; -import UserService from './services/UserService'; +import Login from './routes/Login'; +import NavigationBar from './components/NavigationBar'; export default function ContainerForExtensions() { const [backendIsUp, setBackendIsUp] = useState(null); @@ -72,10 +72,10 @@ export default function ContainerForExtensions() { }; const getExtensions = () => { + setBackendIsUp(true); if (!permissionsLoaded) { return; } - setBackendIsUp(true); if (ability.can('GET', targetUris.extensionListPath)) { HttpService.makeCallToBackend({ path: targetUris.extensionListPath, @@ -97,7 +97,6 @@ export default function ContainerForExtensions() { ]); const routeComponents = () => { - UserService.loginIfNeeded(); return ( } /> } /> + } /> ); }; @@ -126,7 +126,7 @@ export default function ContainerForExtensions() { return ( <> - + ; diff --git a/spiffworkflow-frontend/src/components/LoginHandler.tsx b/spiffworkflow-frontend/src/components/LoginHandler.tsx new file mode 100644 index 00000000..8f8232f0 --- /dev/null +++ b/spiffworkflow-frontend/src/components/LoginHandler.tsx @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import UserService from '../services/UserService'; + +export default function LoginHandler() { + const navigate = useNavigate(); + useEffect(() => { + if (!UserService.isLoggedIn()) { + navigate(`/login?original_url=${UserService.getCurrentLocation()}`); + } + }, [navigate]); + return null; +} diff --git a/spiffworkflow-frontend/src/components/NavigationBar.tsx b/spiffworkflow-frontend/src/components/NavigationBar.tsx index cc9fc7a7..28dba061 100644 --- a/spiffworkflow-frontend/src/components/NavigationBar.tsx +++ b/spiffworkflow-frontend/src/components/NavigationBar.tsx @@ -16,7 +16,7 @@ import { HeaderGlobalAction, HeaderGlobalBar, } from '@carbon/react'; -import { Logout, Login } from '@carbon/icons-react'; +import { Logout } from '@carbon/icons-react'; import { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { Can } from '@casl/react'; @@ -41,10 +41,6 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) { UserService.doLogout(); }; - const handleLogin = () => { - UserService.doLogin(); - }; - const location = useLocation(); const [activeKey, setActiveKey] = useState(''); @@ -152,7 +148,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) { ); }; - const loginAndLogoutAction = () => { + const logoutAction = () => { if (UserService.isLoggedIn()) { return ( <> @@ -168,15 +164,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) { ); } - return ( - - - - ); + return null; }; const configurationElement = () => { @@ -290,7 +278,17 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) { // App.jsx forces login (which redirects to keycloak) so we should never get here if we're not logged in. if (!UserService.isLoggedIn()) { - return null; + return ( + ( +
+ + logo + +
+ )} + /> + ); } if (activeKey && ability && !UserService.onlyGuestTaskCompletion()) { @@ -324,7 +322,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) { {headerMenuItems()} - {loginAndLogoutAction()} + {logoutAction()} )} /> diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx index c8c53cf2..ff8919df 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx @@ -1,8 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { - Button, - // @ts-ignore -} from '@carbon/react'; +import { Button } from '@carbon/react'; import { Can } from '@casl/react'; import { useState } from 'react'; import { diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index d45ae057..c4fa3af8 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -867,3 +867,19 @@ div.onboarding { overflow: hidden; text-overflow: ellipsis; } + +.login-button { + height: 48px; + width: 216px; + padding-right: 0px; + margin-right: 1rem; + margin-bottom: 1rem; +} + +/* + * on the mockup, it looked like about 142px above the heading. + * we already have 32px, so adding the balance. +*/ +.login-page-spacer { + padding-top: 110px; +} diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 311d6eb8..97a14baa 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -460,3 +460,9 @@ export interface JsonSchemaExample { ui: any; data: any; } + +export interface AuthenticationOption { + identifier: string; + label: string; + uri: string; +} diff --git a/spiffworkflow-frontend/src/routes/BaseRoutes.tsx b/spiffworkflow-frontend/src/routes/BaseRoutes.tsx index 205a2b01..229d50e8 100644 --- a/spiffworkflow-frontend/src/routes/BaseRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/BaseRoutes.tsx @@ -13,6 +13,7 @@ import About from './About'; import Page404 from './Page404'; import AdminRedirect from './AdminRedirect'; import RootRoute from './RootRoute'; +import LoginHandler from '../components/LoginHandler'; type OwnProps = { extensionUxElements?: UiSchemaUxElement[] | null; @@ -22,6 +23,7 @@ export default function BaseRoutes({ extensionUxElements }: OwnProps) { return (
+ } /> } /> diff --git a/spiffworkflow-frontend/src/routes/EditorRoutes.tsx b/spiffworkflow-frontend/src/routes/EditorRoutes.tsx index 02a03cc7..b35031eb 100644 --- a/spiffworkflow-frontend/src/routes/EditorRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/EditorRoutes.tsx @@ -3,6 +3,7 @@ import { Routes, Route, useLocation } from 'react-router-dom'; import React, { useEffect } from 'react'; import ProcessModelEditDiagram from './ProcessModelEditDiagram'; import ErrorDisplay from '../components/ErrorDisplay'; +import LoginHandler from '../components/LoginHandler'; export default function EditorRoutes() { const location = useLocation(); @@ -12,6 +13,7 @@ export default function EditorRoutes() { return (
+ {displayErrors ? : null} + {componentsToDisplay}
); diff --git a/spiffworkflow-frontend/src/routes/Login.tsx b/spiffworkflow-frontend/src/routes/Login.tsx new file mode 100644 index 00000000..0c7801df --- /dev/null +++ b/spiffworkflow-frontend/src/routes/Login.tsx @@ -0,0 +1,76 @@ +import { ArrowRight } from '@carbon/icons-react'; +import { useEffect, useState } from 'react'; +import { Button, Grid, Column } from '@carbon/react'; +import { useSearchParams } from 'react-router-dom'; +import { AuthenticationOption } from '../interfaces'; +import HttpService from '../services/HttpService'; +import UserService from '../services/UserService'; + +export default function Login() { + const [searchParams] = useSearchParams(); + const [authenticationOptions, setAuthenticationOptions] = useState< + AuthenticationOption[] | null + >(null); + useEffect(() => { + HttpService.makeCallToBackend({ + path: '/authentication-options', + successCallback: setAuthenticationOptions, + }); + }, []); + + const authenticationOptionButtons = () => { + if (!authenticationOptions) { + return null; + } + const buttons: any = []; + if (authenticationOptions.length === 1) { + UserService.doLogin( + authenticationOptions[0], + searchParams.get('original_url') + ); + return null; + } + authenticationOptions.forEach((option: AuthenticationOption) => { + buttons.push( + + ); + }); + return buttons; + }; + + if (authenticationOptions !== null) { + return ( +
+ + +

+ Log in to SpiffWorkflow +

+
+ + {authenticationOptionButtons()} + +
+
+ ); + } + return null; +} diff --git a/spiffworkflow-frontend/src/services/HttpService.ts b/spiffworkflow-frontend/src/services/HttpService.ts index 4dffa154..44b6293c 100644 --- a/spiffworkflow-frontend/src/services/HttpService.ts +++ b/spiffworkflow-frontend/src/services/HttpService.ts @@ -12,6 +12,8 @@ export const getBasicHeaders = (): Record => { if (UserService.isLoggedIn()) { return { Authorization: `Bearer ${UserService.getAccessToken()}`, + 'Authentication-Identifier': + UserService.getAuthenticationIdentifier() || '', }; } return {}; @@ -77,7 +79,6 @@ backendCallProps) => { fetch(`${BACKEND_BASE_URL}${updatedPath}`, httpArgs) .then((response) => { if (response.status === 401) { - UserService.doLogin(); throw new UnauthenticatedError('You must be authenticated to do this.'); } else if (response.status === 403) { is403 = true; diff --git a/spiffworkflow-frontend/src/services/UserService.ts b/spiffworkflow-frontend/src/services/UserService.ts index d6a14a39..d5066da9 100644 --- a/spiffworkflow-frontend/src/services/UserService.ts +++ b/spiffworkflow-frontend/src/services/UserService.ts @@ -1,6 +1,7 @@ import jwt from 'jwt-decode'; import cookie from 'cookie'; import { BACKEND_BASE_URL } from '../config'; +import { AuthenticationOption } from '../interfaces'; // NOTE: this currently stores the jwt token in local storage // which is considered insecure. Server set cookies seem to be considered @@ -43,15 +44,23 @@ const checkPathForTaskShowParams = () => { return null; }; -const doLogin = () => { +const doLogin = ( + authenticationOption?: AuthenticationOption, + redirectUrl?: string | null +) => { const taskShowParams = checkPathForTaskShowParams(); - const loginParams = [`redirect_url=${getCurrentLocation()}`]; + const loginParams = [`redirect_url=${redirectUrl || getCurrentLocation()}`]; if (taskShowParams) { loginParams.push( `process_instance_id=${taskShowParams.process_instance_id}` ); loginParams.push(`task_guid=${taskShowParams.task_guid}`); } + if (authenticationOption) { + loginParams.push( + `authentication_identifier=${authenticationOption.identifier}` + ); + } const url = `${BACKEND_BASE_URL}/login?${loginParams.join('&')}`; window.location.href = url; }; @@ -60,12 +69,22 @@ const doLogin = () => { const getIdToken = () => { return getCookie('id_token'); }; +const getAccessToken = () => { + return getCookie('access_token'); +}; +const getAuthenticationIdentifier = () => { + return getCookie('authentication_identifier'); +}; + +const isLoggedIn = () => { + return !!getAccessToken(); +}; const doLogout = () => { const idToken = getIdToken(); const frontendBaseUrl = window.location.origin; - let logoutRedirectUrl = `${BACKEND_BASE_URL}/logout?redirect_url=${frontendBaseUrl}&id_token=${idToken}`; + let logoutRedirectUrl = `${BACKEND_BASE_URL}/logout?redirect_url=${frontendBaseUrl}&id_token=${idToken}&authentication_identifier=${getAuthenticationIdentifier()}`; // edge case. if the user is already logged out, just take them somewhere that will force them to sign in. if (idToken === null) { @@ -75,13 +94,6 @@ const doLogout = () => { window.location.href = logoutRedirectUrl; }; -const getAccessToken = () => { - return getCookie('access_token'); -}; -const isLoggedIn = () => { - return !!getAccessToken(); -}; - const getUserEmail = () => { const idToken = getIdToken(); if (idToken) { @@ -153,6 +165,8 @@ const UserService = { doLogin, doLogout, getAccessToken, + getAuthenticationIdentifier, + getCurrentLocation, getPreferredUsername, getUserEmail, isLoggedIn,