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 <jasquat@users.noreply.github.com> Co-authored-by: burnettk <burnettk@users.noreply.github.com>
This commit is contained in:
parent
a48bc8a885
commit
d5b0330609
|
@ -29,8 +29,24 @@ 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_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"
|
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
|
fi
|
||||||
|
|
||||||
if [[ -z "${SPIFFWORKFLOW_BACKEND_ENV:-}" ]]; then
|
if [[ -z "${SPIFFWORKFLOW_BACKEND_ENV:-}" ]]; then
|
||||||
|
|
|
@ -10,8 +10,22 @@ servers:
|
||||||
security: []
|
security: []
|
||||||
|
|
||||||
paths:
|
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:
|
/login:
|
||||||
parameters:
|
parameters:
|
||||||
|
- name: authentication_identifier
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
- name: redirect_url
|
- name: redirect_url
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
|
@ -71,6 +85,11 @@ paths:
|
||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- name: authentication_identifier
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
get:
|
get:
|
||||||
operationId: spiffworkflow_backend.routes.authentication_controller.logout
|
operationId: spiffworkflow_backend.routes.authentication_controller.logout
|
||||||
summary: Logout authenticated user
|
summary: Logout authenticated user
|
||||||
|
@ -96,6 +115,11 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- name: authentication_identifier
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
post:
|
post:
|
||||||
operationId: spiffworkflow_backend.routes.authentication_controller.login_with_access_token
|
operationId: spiffworkflow_backend.routes.authentication_controller.login_with_access_token
|
||||||
summary: Authenticate user for API access with an openid token already posessed.
|
summary: Authenticate user for API access with an openid token already posessed.
|
||||||
|
@ -110,6 +134,12 @@ paths:
|
||||||
$ref: "#/components/schemas/OkTrue"
|
$ref: "#/components/schemas/OkTrue"
|
||||||
|
|
||||||
/login_api:
|
/login_api:
|
||||||
|
parameters:
|
||||||
|
- name: authentication_identifier
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
get:
|
get:
|
||||||
operationId: spiffworkflow_backend.routes.authentication_controller.login_api
|
operationId: spiffworkflow_backend.routes.authentication_controller.login_api
|
||||||
summary: Authenticate user for API access
|
summary: Authenticate user for API access
|
||||||
|
|
|
@ -195,6 +195,17 @@ 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:
|
||||||
|
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()
|
thread_local_data = threading.local()
|
||||||
app.config["THREAD_LOCAL_DATA"] = thread_local_data
|
app.config["THREAD_LOCAL_DATA"] = thread_local_data
|
||||||
_set_up_tenant_specific_fields_as_list_of_strings(app)
|
_set_up_tenant_specific_fields_as_list_of_strings(app)
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import re
|
import re
|
||||||
from os import environ
|
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
|
# 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
|
# 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.
|
# 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
|
globals()[variable_name] = value_to_return
|
||||||
|
|
||||||
|
|
||||||
|
configs_with_structures = normalized_environment(environ)
|
||||||
|
|
||||||
### basic
|
### basic
|
||||||
config_from_env("FLASK_SESSION_SECRET_KEY")
|
config_from_env("FLASK_SESSION_SECRET_KEY")
|
||||||
config_from_env("SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR")
|
config_from_env("SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR")
|
||||||
|
@ -75,24 +79,28 @@ config_from_env("SPIFFWORKFLOW_BACKEND_DATABASE_POOL_SIZE")
|
||||||
|
|
||||||
### open id
|
### open id
|
||||||
config_from_env("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED", default=False)
|
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)
|
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
|
# 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
|
# and store in the user table's tenant_specific_field_n columns. You can have up to three items in this
|
||||||
# comma-separated list.
|
# comma-separated list.
|
||||||
config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_TENANT_SPECIFIC_FIELDS")
|
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
|
### logs
|
||||||
# loggers to use is a comma separated list of logger prefixes that we will be converted to list of strings
|
# loggers to use is a comma separated list of logger prefixes that we will be converted to list of strings
|
||||||
config_from_env("SPIFFWORKFLOW_BACKEND_LOGGERS_TO_USE")
|
config_from_env("SPIFFWORKFLOW_BACKEND_LOGGERS_TO_USE")
|
||||||
|
|
|
@ -0,0 +1,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
|
|
@ -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:
|
groups:
|
||||||
admin:
|
admin:
|
||||||
users: [admin@spiffworkflow.org]
|
users: [admin@spiffworkflow.org, nelson@spiffworkflow.org]
|
||||||
group1:
|
group1:
|
||||||
users: [jason@sartography.com, kevin@sartography.com]
|
users: [jason@sartography.com, kevin@sartography.com]
|
||||||
group2:
|
group2:
|
||||||
|
|
|
@ -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']
|
# 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) -> None:
|
||||||
"""Verify the token for the user (if provided).
|
"""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)
|
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"):
|
if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"):
|
||||||
AuthenticationService.create_guest_token(
|
AuthenticationService.create_guest_token(
|
||||||
username=SPIFF_NO_AUTH_USER,
|
username=SPIFF_NO_AUTH_USER,
|
||||||
group_identifier=SPIFF_NO_AUTH_GROUP,
|
group_identifier=SPIFF_NO_AUTH_GROUP,
|
||||||
permission_target="/*",
|
permission_target="/*",
|
||||||
auth_token_properties={"authentication_disabled": True},
|
auth_token_properties={"authentication_disabled": True},
|
||||||
|
authentication_identifier=authentication_identifier,
|
||||||
)
|
)
|
||||||
return redirect(redirect_url)
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
@ -103,23 +114,31 @@ def login(redirect_url: str = "/", process_instance_id: int | None = None, task_
|
||||||
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},
|
||||||
|
authentication_identifier=authentication_identifier,
|
||||||
)
|
)
|
||||||
return redirect(redirect_url)
|
return redirect(redirect_url)
|
||||||
|
|
||||||
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"))
|
login_redirect_url = AuthenticationService().get_login_redirect_url(
|
||||||
|
state.decode("UTF-8"), authentication_identifier=authentication_identifier
|
||||||
|
)
|
||||||
return redirect(login_redirect_url)
|
return redirect(login_redirect_url)
|
||||||
|
|
||||||
|
|
||||||
def login_return(code: str, state: str, session_state: str = "") -> Response | None:
|
def login_return(code: str, state: str, session_state: str = "") -> Response | None:
|
||||||
state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8"))
|
state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8"))
|
||||||
state_redirect_url = state_dict["redirect_url"]
|
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:
|
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)
|
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:
|
if user_info and "error" not in user_info:
|
||||||
user_model = AuthorizationService.create_user_from_sign_in(user_info)
|
user_model = AuthorizationService.create_user_from_sign_in(user_info)
|
||||||
g.user = user_model.id
|
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 = current_app.config["THREAD_LOCAL_DATA"]
|
||||||
tld.new_access_token = auth_token_object["id_token"]
|
tld.new_access_token = auth_token_object["id_token"]
|
||||||
tld.new_id_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)
|
return redirect(redirect_url)
|
||||||
|
|
||||||
raise ApiError(
|
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
|
# 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)
|
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:
|
if user_info and "error" not in user_info:
|
||||||
AuthorizationService.create_user_from_sign_in(user_info)
|
AuthorizationService.create_user_from_sign_in(user_info)
|
||||||
else:
|
else:
|
||||||
|
@ -164,9 +186,9 @@ def login_with_access_token(access_token: str) -> Response:
|
||||||
return make_response(jsonify({"ok": True}))
|
return make_response(jsonify({"ok": True}))
|
||||||
|
|
||||||
|
|
||||||
def login_api() -> Response:
|
def login_api(authentication_identifier: str) -> Response:
|
||||||
redirect_url = "/v1.0/login_api_return"
|
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)
|
login_redirect_url = AuthenticationService().get_login_redirect_url(state.decode("UTF-8"), redirect_url)
|
||||||
return redirect(login_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
|
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:
|
if redirect_url is None:
|
||||||
redirect_url = ""
|
redirect_url = ""
|
||||||
tld = current_app.config["THREAD_LOCAL_DATA"]
|
tld = current_app.config["THREAD_LOCAL_DATA"]
|
||||||
tld.user_has_logged_out = True
|
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:
|
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:
|
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)
|
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:
|
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("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("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()
|
_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")
|
delattr(tld, "new_access_token")
|
||||||
if hasattr(tld, "new_id_token"):
|
if hasattr(tld, "new_id_token"):
|
||||||
delattr(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"):
|
if hasattr(tld, "user_has_logged_out"):
|
||||||
delattr(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():
|
elif "iss" in decoded_token.keys():
|
||||||
user_info = None
|
user_info = None
|
||||||
|
authentication_identifier = request.headers["authentication_identifier"]
|
||||||
try:
|
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
|
user_info = decoded_token
|
||||||
except TokenExpiredError as token_expired_error:
|
except TokenExpiredError as token_expired_error:
|
||||||
# Try to refresh the token
|
# Try to refresh the token
|
||||||
|
@ -327,7 +362,9 @@ def _get_user_model_from_token(token: str) -> UserModel | None:
|
||||||
if user:
|
if user:
|
||||||
refresh_token = AuthenticationService.get_refresh_token(user.id)
|
refresh_token = AuthenticationService.get_refresh_token(user.id)
|
||||||
if refresh_token:
|
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:
|
if auth_token and "error" not in auth_token and "id_token" in auth_token:
|
||||||
tld = current_app.config["THREAD_LOCAL_DATA"]
|
tld = current_app.config["THREAD_LOCAL_DATA"]
|
||||||
tld.new_access_token = auth_token["id_token"]
|
tld.new_access_token = auth_token["id_token"]
|
||||||
|
|
|
@ -4,6 +4,7 @@ from flask import request
|
||||||
from flask.wrappers import Response
|
from flask.wrappers import Response
|
||||||
|
|
||||||
from spiffworkflow_backend import get_version_info_data
|
from spiffworkflow_backend import get_version_info_data
|
||||||
|
from spiffworkflow_backend.services.authentication_service import AuthenticationService
|
||||||
|
|
||||||
|
|
||||||
def test_raise_error() -> Response:
|
def test_raise_error() -> Response:
|
||||||
|
@ -15,4 +16,4 @@ def version_info() -> Response:
|
||||||
|
|
||||||
|
|
||||||
def url_info() -> Response:
|
def url_info() -> Response:
|
||||||
return make_response({"url": request.url}, 200)
|
return make_response({"url": request.url, "cache": AuthenticationService.ENDPOINT_CACHE}, 200)
|
||||||
|
|
|
@ -5,6 +5,7 @@ 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 TypedDict
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import requests
|
import requests
|
||||||
|
@ -30,51 +31,94 @@ class AuthenticationProviderTypes(enum.Enum):
|
||||||
internal = "internal"
|
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:
|
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
|
@classmethod
|
||||||
def client_id() -> str:
|
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."""
|
"""Returns the client id from the config."""
|
||||||
config: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID", "")
|
config: str = cls.authentication_option_for_identifier(authentication_identifier)["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", "")
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""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"
|
openid_config_url = f"{cls.server_url(authentication_identifier)}/.well-known/openid-configuration"
|
||||||
if name not in AuthenticationService.ENDPOINT_CACHE:
|
if authentication_identifier not in cls.ENDPOINT_CACHE:
|
||||||
|
cls.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)
|
||||||
AuthenticationService.ENDPOINT_CACHE = response.json()
|
AuthenticationService.ENDPOINT_CACHE[authentication_identifier] = response.json()
|
||||||
except requests.exceptions.ConnectionError as ce:
|
except requests.exceptions.ConnectionError as ce:
|
||||||
raise OpenIdConnectionError(f"Cannot connect to given open id url: {openid_config_url}") from 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}")
|
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
|
return config
|
||||||
|
|
||||||
@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"])
|
||||||
|
|
||||||
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:
|
if redirect_url is None:
|
||||||
redirect_url = f"{self.get_backend_url()}/v1.0/logout_return"
|
redirect_url = f"{self.get_backend_url()}/v1.0/logout_return"
|
||||||
request_url = (
|
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"?post_logout_redirect_uri={redirect_url}&"
|
||||||
+ f"id_token_hint={id_token}"
|
+ f"id_token_hint={id_token}"
|
||||||
)
|
)
|
||||||
|
@ -82,24 +126,35 @@ class AuthenticationService:
|
||||||
return redirect(request_url)
|
return redirect(request_url)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_state(redirect_url: str) -> bytes:
|
def generate_state(redirect_url: str, authentication_identifier: str) -> bytes:
|
||||||
state = base64.b64encode(bytes(str({"redirect_url": redirect_url}), "UTF-8"))
|
state = base64.b64encode(
|
||||||
|
bytes(str({"redirect_url": redirect_url, "authentication_identifier": authentication_identifier}), "UTF-8")
|
||||||
|
)
|
||||||
return state
|
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}"
|
return_redirect_url = f"{self.get_backend_url()}{redirect_url}"
|
||||||
login_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}&"
|
+ f"?state={state}&"
|
||||||
+ "response_type=code&"
|
+ "response_type=code&"
|
||||||
+ f"client_id={self.client_id()}&"
|
+ f"client_id={self.client_id(authentication_identifier)}&"
|
||||||
+ "scope=openid profile email&"
|
+ "scope=openid profile email&"
|
||||||
+ f"redirect_uri={return_redirect_url}"
|
+ f"redirect_uri={return_redirect_url}"
|
||||||
)
|
)
|
||||||
|
print(f"login_redirect_url: {login_redirect_url}")
|
||||||
return login_redirect_url
|
return login_redirect_url
|
||||||
|
|
||||||
def get_auth_token_object(self, code: str, redirect_url: str = "/v1.0/login_return") -> dict:
|
def get_auth_token_object(
|
||||||
backend_basic_auth_string = f"{self.client_id()}:{self.secret_key()}"
|
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_bytes = bytes(backend_basic_auth_string, encoding="ascii")
|
||||||
backend_basic_auth = base64.b64encode(backend_basic_auth_bytes)
|
backend_basic_auth = base64.b64encode(backend_basic_auth_bytes)
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -112,14 +167,17 @@ class AuthenticationService:
|
||||||
"redirect_uri": f"{self.get_backend_url()}{redirect_url}",
|
"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)
|
response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
|
||||||
auth_token_object: dict = json.loads(response.text)
|
auth_token_object: dict = json.loads(response.text)
|
||||||
|
print(f"auth_token_object: {auth_token_object}")
|
||||||
return auth_token_object
|
return auth_token_object
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation."""
|
||||||
valid = True
|
valid = True
|
||||||
now = round(time.time())
|
now = round(time.time())
|
||||||
|
@ -136,28 +194,33 @@ class AuthenticationService:
|
||||||
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(), "account")
|
valid_audience_values = (cls.client_id(authentication_identifier), "account")
|
||||||
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():
|
if iss != cls.server_url(authentication_identifier):
|
||||||
current_app.logger.error(
|
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
|
valid = False
|
||||||
# aud could be an array or a string
|
# aud could be an array or a string
|
||||||
elif len(overlapping_aud_values) < 1:
|
elif len(overlapping_aud_values) < 1:
|
||||||
current_app.logger.error(
|
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
|
valid = False
|
||||||
elif azp and azp not in (
|
elif azp and azp not in (
|
||||||
cls.client_id(),
|
cls.client_id(authentication_identifier),
|
||||||
"account",
|
"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
|
valid = False
|
||||||
# make sure issued at time is not in the future
|
# make sure issued at time is not in the future
|
||||||
elif now + iat_clock_skew_leeway < iat:
|
elif now + iat_clock_skew_leeway < iat:
|
||||||
|
@ -175,8 +238,8 @@ class AuthenticationService:
|
||||||
f"AUD: {aud} "
|
f"AUD: {aud} "
|
||||||
f"AZP: {azp} "
|
f"AZP: {azp} "
|
||||||
f"IAT: {iat} "
|
f"IAT: {iat} "
|
||||||
f"SERVER_URL: {cls.server_url()} "
|
f"SERVER_URL: {cls.server_url(authentication_identifier)} "
|
||||||
f"CLIENT_ID: {cls.client_id()} "
|
f"CLIENT_ID: {cls.client_id(authentication_identifier)} "
|
||||||
f"NOW: {now}"
|
f"NOW: {now}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -208,9 +271,11 @@ class AuthenticationService:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""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_bytes = bytes(backend_basic_auth_string, encoding="ascii")
|
||||||
backend_basic_auth = base64.b64encode(backend_basic_auth_bytes)
|
backend_basic_auth = base64.b64encode(backend_basic_auth_bytes)
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -221,11 +286,13 @@ class AuthenticationService:
|
||||||
data = {
|
data = {
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
"client_id": cls.client_id(),
|
"client_id": cls.client_id(authentication_identifier),
|
||||||
"client_secret": cls.secret_key(),
|
"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)
|
response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
|
||||||
auth_token_object: dict = json.loads(response.text)
|
auth_token_object: dict = json.loads(response.text)
|
||||||
|
@ -270,6 +337,7 @@ class AuthenticationService:
|
||||||
cls,
|
cls,
|
||||||
username: str,
|
username: str,
|
||||||
group_identifier: str,
|
group_identifier: str,
|
||||||
|
authentication_identifier: str,
|
||||||
permission_target: str | None = None,
|
permission_target: str | None = None,
|
||||||
permission: str = "all",
|
permission: str = "all",
|
||||||
auth_token_properties: dict | None = None,
|
auth_token_properties: dict | None = None,
|
||||||
|
@ -284,3 +352,4 @@ class AuthenticationService:
|
||||||
tld = current_app.config["THREAD_LOCAL_DATA"]
|
tld = current_app.config["THREAD_LOCAL_DATA"]
|
||||||
tld.new_access_token = g.token
|
tld.new_access_token = g.token
|
||||||
tld.new_id_token = g.token
|
tld.new_id_token = g.token
|
||||||
|
tld.new_authentication_identifier = authentication_identifier
|
||||||
|
|
|
@ -271,6 +271,7 @@ class AuthorizationService:
|
||||||
api_view_function
|
api_view_function
|
||||||
and api_view_function.__name__.startswith("login")
|
and api_view_function.__name__.startswith("login")
|
||||||
or api_view_function.__name__.startswith("logout")
|
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__.startswith("prom")
|
||||||
or api_view_function.__name__ == "url_info"
|
or api_view_function.__name__ == "url_info"
|
||||||
or api_view_function.__name__.startswith("metric")
|
or api_view_function.__name__.startswith("metric")
|
||||||
|
|
|
@ -18,7 +18,7 @@ from tests.spiffworkflow_backend.helpers.base_test import BaseTest
|
||||||
class TestAuthentication(BaseTest):
|
class TestAuthentication(BaseTest):
|
||||||
def test_get_login_state(self) -> None:
|
def test_get_login_state(self) -> None:
|
||||||
redirect_url = "http://example.com/"
|
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"))
|
state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8"))
|
||||||
|
|
||||||
assert isinstance(state_dict, dict)
|
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):
|
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 = self.find_or_create_user("testing@e.com")
|
||||||
user.email = "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.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
access_token = user.encode_auth_token(
|
access_token = user.encode_auth_token(
|
||||||
{
|
{
|
||||||
"groups": ["group_one", "group_two"],
|
"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",
|
"aud": "spiffworkflow-backend",
|
||||||
"iat": round(time.time()),
|
"iat": round(time.time()),
|
||||||
"exp": round(time.time()) + 1000,
|
"exp": round(time.time()) + 1000,
|
||||||
|
@ -49,7 +49,7 @@ class TestAuthentication(BaseTest):
|
||||||
)
|
)
|
||||||
response = None
|
response = None
|
||||||
response = client.post(
|
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 response.status_code == 200
|
||||||
assert len(user.groups) == 3
|
assert len(user.groups) == 3
|
||||||
|
@ -59,14 +59,14 @@ class TestAuthentication(BaseTest):
|
||||||
access_token = user.encode_auth_token(
|
access_token = user.encode_auth_token(
|
||||||
{
|
{
|
||||||
"groups": ["group_one"],
|
"groups": ["group_one"],
|
||||||
"iss": app.config["SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"],
|
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
|
||||||
"aud": "spiffworkflow-backend",
|
"aud": "spiffworkflow-backend",
|
||||||
"iat": round(time.time()),
|
"iat": round(time.time()),
|
||||||
"exp": round(time.time()) + 1000,
|
"exp": round(time.time()) + 1000,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
response = client.post(
|
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 response.status_code == 200
|
||||||
user = UserModel.query.filter_by(username=user.username).first()
|
user = UserModel.query.filter_by(username=user.username).first()
|
||||||
|
|
|
@ -489,7 +489,7 @@ class TestTasksController(BaseTest):
|
||||||
# log in a guest user to complete the tasks
|
# log in a guest user to complete the tasks
|
||||||
redirect_url = "/test-redirect-dne"
|
redirect_url = "/test-redirect-dne"
|
||||||
response = client.get(
|
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.status_code == 302
|
||||||
assert response.location == redirect_url
|
assert response.location == redirect_url
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { Content } from '@carbon/react';
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import NavigationBar from './components/NavigationBar';
|
|
||||||
|
|
||||||
import ScrollToTop from './components/ScrollToTop';
|
import ScrollToTop from './components/ScrollToTop';
|
||||||
import EditorRoutes from './routes/EditorRoutes';
|
import EditorRoutes from './routes/EditorRoutes';
|
||||||
|
@ -18,7 +17,8 @@ import HttpService from './services/HttpService';
|
||||||
import { ErrorBoundaryFallback } from './ErrorBoundaryFallack';
|
import { ErrorBoundaryFallback } from './ErrorBoundaryFallack';
|
||||||
import BaseRoutes from './routes/BaseRoutes';
|
import BaseRoutes from './routes/BaseRoutes';
|
||||||
import BackendIsDown from './routes/BackendIsDown';
|
import BackendIsDown from './routes/BackendIsDown';
|
||||||
import UserService from './services/UserService';
|
import Login from './routes/Login';
|
||||||
|
import NavigationBar from './components/NavigationBar';
|
||||||
|
|
||||||
export default function ContainerForExtensions() {
|
export default function ContainerForExtensions() {
|
||||||
const [backendIsUp, setBackendIsUp] = useState<boolean | null>(null);
|
const [backendIsUp, setBackendIsUp] = useState<boolean | null>(null);
|
||||||
|
@ -72,10 +72,10 @@ export default function ContainerForExtensions() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExtensions = () => {
|
const getExtensions = () => {
|
||||||
|
setBackendIsUp(true);
|
||||||
if (!permissionsLoaded) {
|
if (!permissionsLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBackendIsUp(true);
|
|
||||||
if (ability.can('GET', targetUris.extensionListPath)) {
|
if (ability.can('GET', targetUris.extensionListPath)) {
|
||||||
HttpService.makeCallToBackend({
|
HttpService.makeCallToBackend({
|
||||||
path: targetUris.extensionListPath,
|
path: targetUris.extensionListPath,
|
||||||
|
@ -97,7 +97,6 @@ export default function ContainerForExtensions() {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const routeComponents = () => {
|
const routeComponents = () => {
|
||||||
UserService.loginIfNeeded();
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
|
@ -106,6 +105,7 @@ export default function ContainerForExtensions() {
|
||||||
/>
|
/>
|
||||||
<Route path="/editor/*" element={<EditorRoutes />} />
|
<Route path="/editor/*" element={<EditorRoutes />} />
|
||||||
<Route path="/extensions/:page_identifier" element={<Extension />} />
|
<Route path="/extensions/:page_identifier" element={<Extension />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -126,7 +126,7 @@ export default function ContainerForExtensions() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavigationBar extensionUxElements={extensionUxElements} />
|
<NavigationBar extensionUxElements={extensionUxElements} />;
|
||||||
<Content className={contentClassName}>
|
<Content className={contentClassName}>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
|
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ import {
|
||||||
HeaderGlobalAction,
|
HeaderGlobalAction,
|
||||||
HeaderGlobalBar,
|
HeaderGlobalBar,
|
||||||
} from '@carbon/react';
|
} from '@carbon/react';
|
||||||
import { Logout, Login } from '@carbon/icons-react';
|
import { Logout } from '@carbon/icons-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Can } from '@casl/react';
|
import { Can } from '@casl/react';
|
||||||
|
@ -41,10 +41,6 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
|
||||||
UserService.doLogout();
|
UserService.doLogout();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = () => {
|
|
||||||
UserService.doLogin();
|
|
||||||
};
|
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [activeKey, setActiveKey] = useState<string>('');
|
const [activeKey, setActiveKey] = useState<string>('');
|
||||||
|
|
||||||
|
@ -152,7 +148,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loginAndLogoutAction = () => {
|
const logoutAction = () => {
|
||||||
if (UserService.isLoggedIn()) {
|
if (UserService.isLoggedIn()) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -168,15 +164,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return null;
|
||||||
<HeaderGlobalAction
|
|
||||||
data-qa="login-button"
|
|
||||||
aria-label="Login"
|
|
||||||
onClick={handleLogin}
|
|
||||||
>
|
|
||||||
<Login />
|
|
||||||
</HeaderGlobalAction>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configurationElement = () => {
|
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.
|
// App.jsx forces login (which redirects to keycloak) so we should never get here if we're not logged in.
|
||||||
if (!UserService.isLoggedIn()) {
|
if (!UserService.isLoggedIn()) {
|
||||||
return null;
|
return (
|
||||||
|
<HeaderContainer
|
||||||
|
render={() => (
|
||||||
|
<Header aria-label="IBM Platform Name" className="cds--g100">
|
||||||
|
<HeaderName href="/" prefix="" data-qa="spiffworkflow-logo">
|
||||||
|
<img src={logo} className="app-logo" alt="logo" />
|
||||||
|
</HeaderName>
|
||||||
|
</Header>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeKey && ability && !UserService.onlyGuestTaskCompletion()) {
|
if (activeKey && ability && !UserService.onlyGuestTaskCompletion()) {
|
||||||
|
@ -324,7 +322,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
|
||||||
<HeaderSideNavItems>{headerMenuItems()}</HeaderSideNavItems>
|
<HeaderSideNavItems>{headerMenuItems()}</HeaderSideNavItems>
|
||||||
</SideNavItems>
|
</SideNavItems>
|
||||||
</SideNav>
|
</SideNav>
|
||||||
<HeaderGlobalBar>{loginAndLogoutAction()}</HeaderGlobalBar>
|
<HeaderGlobalBar>{logoutAction()}</HeaderGlobalBar>
|
||||||
</Header>
|
</Header>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { Button } from '@carbon/react';
|
||||||
Button,
|
|
||||||
// @ts-ignore
|
|
||||||
} from '@carbon/react';
|
|
||||||
import { Can } from '@casl/react';
|
import { Can } from '@casl/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -867,3 +867,19 @@ div.onboarding {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -460,3 +460,9 @@ export interface JsonSchemaExample {
|
||||||
ui: any;
|
ui: any;
|
||||||
data: any;
|
data: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthenticationOption {
|
||||||
|
identifier: string;
|
||||||
|
label: string;
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import About from './About';
|
||||||
import Page404 from './Page404';
|
import Page404 from './Page404';
|
||||||
import AdminRedirect from './AdminRedirect';
|
import AdminRedirect from './AdminRedirect';
|
||||||
import RootRoute from './RootRoute';
|
import RootRoute from './RootRoute';
|
||||||
|
import LoginHandler from '../components/LoginHandler';
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
extensionUxElements?: UiSchemaUxElement[] | null;
|
extensionUxElements?: UiSchemaUxElement[] | null;
|
||||||
|
@ -22,6 +23,7 @@ export default function BaseRoutes({ extensionUxElements }: OwnProps) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed-width-container">
|
<div className="fixed-width-container">
|
||||||
<ErrorDisplay />
|
<ErrorDisplay />
|
||||||
|
<LoginHandler />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RootRoute />} />
|
<Route path="/" element={<RootRoute />} />
|
||||||
<Route path="tasks/*" element={<HomeRoutes />} />
|
<Route path="tasks/*" element={<HomeRoutes />} />
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { Routes, Route, useLocation } from 'react-router-dom';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import ProcessModelEditDiagram from './ProcessModelEditDiagram';
|
import ProcessModelEditDiagram from './ProcessModelEditDiagram';
|
||||||
import ErrorDisplay from '../components/ErrorDisplay';
|
import ErrorDisplay from '../components/ErrorDisplay';
|
||||||
|
import LoginHandler from '../components/LoginHandler';
|
||||||
|
|
||||||
export default function EditorRoutes() {
|
export default function EditorRoutes() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
@ -12,6 +13,7 @@ export default function EditorRoutes() {
|
||||||
return (
|
return (
|
||||||
<div className="full-width-container no-center-stuff">
|
<div className="full-width-container no-center-stuff">
|
||||||
<ErrorDisplay />
|
<ErrorDisplay />
|
||||||
|
<LoginHandler />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="process-models/:process_model_id/files"
|
path="process-models/:process_model_id/files"
|
||||||
|
|
|
@ -23,6 +23,7 @@ import ErrorDisplay from '../components/ErrorDisplay';
|
||||||
import FormattingService from '../services/FormattingService';
|
import FormattingService from '../services/FormattingService';
|
||||||
import ProcessInstanceRun from '../components/ProcessInstanceRun';
|
import ProcessInstanceRun from '../components/ProcessInstanceRun';
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer';
|
import MarkdownRenderer from '../components/MarkdownRenderer';
|
||||||
|
import LoginHandler from '../components/LoginHandler';
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
displayErrors?: boolean;
|
displayErrors?: boolean;
|
||||||
|
@ -366,6 +367,7 @@ export default function Extension({ displayErrors = true }: OwnProps) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed-width-container">
|
<div className="fixed-width-container">
|
||||||
{displayErrors ? <ErrorDisplay /> : null}
|
{displayErrors ? <ErrorDisplay /> : null}
|
||||||
|
<LoginHandler />
|
||||||
{componentsToDisplay}
|
{componentsToDisplay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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(
|
||||||
|
<Button
|
||||||
|
data-qa={`login-button-${option.identifier}`}
|
||||||
|
size="lg"
|
||||||
|
className="login-button"
|
||||||
|
renderIcon={ArrowRight}
|
||||||
|
onClick={() =>
|
||||||
|
UserService.doLogin(option, searchParams.get('original_url'))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authenticationOptions !== null) {
|
||||||
|
return (
|
||||||
|
<div className="fixed-width-container login-page-spacer">
|
||||||
|
<Grid>
|
||||||
|
<Column
|
||||||
|
lg={{ span: 5, offset: 6 }}
|
||||||
|
md={{ span: 4, offset: 2 }}
|
||||||
|
sm={{ span: 4, offset: 0 }}
|
||||||
|
>
|
||||||
|
<h1 className="with-large-bottom-margin">
|
||||||
|
Log in to SpiffWorkflow
|
||||||
|
</h1>
|
||||||
|
</Column>
|
||||||
|
<Column
|
||||||
|
lg={{ span: 8, offset: 5 }}
|
||||||
|
md={{ span: 5, offset: 2 }}
|
||||||
|
sm={{ span: 4, offset: 0 }}
|
||||||
|
>
|
||||||
|
{authenticationOptionButtons()}
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
|
@ -12,6 +12,8 @@ export const getBasicHeaders = (): Record<string, string> => {
|
||||||
if (UserService.isLoggedIn()) {
|
if (UserService.isLoggedIn()) {
|
||||||
return {
|
return {
|
||||||
Authorization: `Bearer ${UserService.getAccessToken()}`,
|
Authorization: `Bearer ${UserService.getAccessToken()}`,
|
||||||
|
'Authentication-Identifier':
|
||||||
|
UserService.getAuthenticationIdentifier() || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
|
@ -77,7 +79,6 @@ backendCallProps) => {
|
||||||
fetch(`${BACKEND_BASE_URL}${updatedPath}`, httpArgs)
|
fetch(`${BACKEND_BASE_URL}${updatedPath}`, httpArgs)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
UserService.doLogin();
|
|
||||||
throw new UnauthenticatedError('You must be authenticated to do this.');
|
throw new UnauthenticatedError('You must be authenticated to do this.');
|
||||||
} else if (response.status === 403) {
|
} else if (response.status === 403) {
|
||||||
is403 = true;
|
is403 = true;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import jwt from 'jwt-decode';
|
import jwt from 'jwt-decode';
|
||||||
import cookie from 'cookie';
|
import cookie from 'cookie';
|
||||||
import { BACKEND_BASE_URL } from '../config';
|
import { BACKEND_BASE_URL } from '../config';
|
||||||
|
import { AuthenticationOption } from '../interfaces';
|
||||||
|
|
||||||
// NOTE: this currently stores the jwt token in local storage
|
// NOTE: this currently stores the jwt token in local storage
|
||||||
// which is considered insecure. Server set cookies seem to be considered
|
// which is considered insecure. Server set cookies seem to be considered
|
||||||
|
@ -43,15 +44,23 @@ const checkPathForTaskShowParams = () => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const doLogin = () => {
|
const doLogin = (
|
||||||
|
authenticationOption?: AuthenticationOption,
|
||||||
|
redirectUrl?: string | null
|
||||||
|
) => {
|
||||||
const taskShowParams = checkPathForTaskShowParams();
|
const taskShowParams = checkPathForTaskShowParams();
|
||||||
const loginParams = [`redirect_url=${getCurrentLocation()}`];
|
const loginParams = [`redirect_url=${redirectUrl || getCurrentLocation()}`];
|
||||||
if (taskShowParams) {
|
if (taskShowParams) {
|
||||||
loginParams.push(
|
loginParams.push(
|
||||||
`process_instance_id=${taskShowParams.process_instance_id}`
|
`process_instance_id=${taskShowParams.process_instance_id}`
|
||||||
);
|
);
|
||||||
loginParams.push(`task_guid=${taskShowParams.task_guid}`);
|
loginParams.push(`task_guid=${taskShowParams.task_guid}`);
|
||||||
}
|
}
|
||||||
|
if (authenticationOption) {
|
||||||
|
loginParams.push(
|
||||||
|
`authentication_identifier=${authenticationOption.identifier}`
|
||||||
|
);
|
||||||
|
}
|
||||||
const url = `${BACKEND_BASE_URL}/login?${loginParams.join('&')}`;
|
const url = `${BACKEND_BASE_URL}/login?${loginParams.join('&')}`;
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
};
|
};
|
||||||
|
@ -60,12 +69,22 @@ const doLogin = () => {
|
||||||
const getIdToken = () => {
|
const getIdToken = () => {
|
||||||
return getCookie('id_token');
|
return getCookie('id_token');
|
||||||
};
|
};
|
||||||
|
const getAccessToken = () => {
|
||||||
|
return getCookie('access_token');
|
||||||
|
};
|
||||||
|
const getAuthenticationIdentifier = () => {
|
||||||
|
return getCookie('authentication_identifier');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoggedIn = () => {
|
||||||
|
return !!getAccessToken();
|
||||||
|
};
|
||||||
|
|
||||||
const doLogout = () => {
|
const doLogout = () => {
|
||||||
const idToken = getIdToken();
|
const idToken = getIdToken();
|
||||||
|
|
||||||
const frontendBaseUrl = window.location.origin;
|
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.
|
// edge case. if the user is already logged out, just take them somewhere that will force them to sign in.
|
||||||
if (idToken === null) {
|
if (idToken === null) {
|
||||||
|
@ -75,13 +94,6 @@ const doLogout = () => {
|
||||||
window.location.href = logoutRedirectUrl;
|
window.location.href = logoutRedirectUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAccessToken = () => {
|
|
||||||
return getCookie('access_token');
|
|
||||||
};
|
|
||||||
const isLoggedIn = () => {
|
|
||||||
return !!getAccessToken();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUserEmail = () => {
|
const getUserEmail = () => {
|
||||||
const idToken = getIdToken();
|
const idToken = getIdToken();
|
||||||
if (idToken) {
|
if (idToken) {
|
||||||
|
@ -153,6 +165,8 @@ const UserService = {
|
||||||
doLogin,
|
doLogin,
|
||||||
doLogout,
|
doLogout,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
|
getAuthenticationIdentifier,
|
||||||
|
getCurrentLocation,
|
||||||
getPreferredUsername,
|
getPreferredUsername,
|
||||||
getUserEmail,
|
getUserEmail,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
|
Loading…
Reference in New Issue