diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py index e519599c9..d300acd23 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py @@ -234,6 +234,7 @@ def setup_config(app: Flask) -> None: "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"), + "additional_valid_client_ids": app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_ADDITIONAL_VALID_CLIENT_IDS"), } ] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index 019481632..91deaacaf 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -106,6 +106,15 @@ else: SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL = url_config config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID", default="spiffworkflow-backend") config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY", default="JXeQExm0JhQPLumgHtIIqf52bDalHz0q") + + # comma-separated list of client ids that can be successfully validated against. + # useful for api users that will login to a different client on the same realm but from something external to backend. + # Example: + # client-A is configured as the main client id in backend + # client-B is for api users who will authenticate directly with keycloak + # if client-B is added to this list, then an api user can auth with keycloak + # and use that token successfully with backend + config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_ADDITIONAL_VALID_CLIENT_IDS") else: SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS = [ { @@ -114,6 +123,7 @@ else: "uri": "http://localhost:7002/realms/spiffworkflow", "client_id": "spiffworkflow-backend", "client_secret": "JXeQExm0JhQPLumgHtIIqf52bDalHz0q", + "additional_valid_client_ids": None, } ] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py index 2f2423e0f..f12860983 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py @@ -1,11 +1,18 @@ import base64 import enum import json +import sys import time from hashlib import sha256 from hmac import HMAC from hmac import compare_digest -from typing import TypedDict + +if sys.version_info < (3, 11): + from typing_extensions import NotRequired + from typing_extensions import TypedDict +else: + from typing import NotRequired + from typing import TypedDict import jwt import requests @@ -36,6 +43,7 @@ class AuthenticationOptionForApi(TypedDict): identifier: str label: str uri: str + additional_valid_client_ids: NotRequired[str] class AuthenticationOption(AuthenticationOptionForApi): @@ -163,6 +171,24 @@ class AuthenticationService: auth_token_object: dict = json.loads(response.text) return auth_token_object + @classmethod + def is_valid_azp(cls, authentication_identifier: str, azp: str | None) -> bool: + # not all open id token include an azp so only check if present + if azp is None: + return True + + valid_client_ids = [cls.client_id(authentication_identifier), "account"] + if ( + "additional_valid_client_ids" in cls.authentication_option_for_identifier(authentication_identifier) + and cls.authentication_option_for_identifier(authentication_identifier)["additional_valid_client_ids"] is not None + ): + additional_valid_client_ids = cls.authentication_option_for_identifier(authentication_identifier)[ + "additional_valid_client_ids" + ].split(",") + additional_valid_client_ids = [value.strip() for value in additional_valid_client_ids] + valid_client_ids = valid_client_ids + additional_valid_client_ids + return azp in valid_client_ids + @classmethod def validate_id_or_access_token(cls, token: str, authentication_identifier: str) -> bool: """Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation.""" @@ -198,10 +224,7 @@ class AuthenticationService: f"TOKEN INVALID because audience '{aud}' does not match client id '{cls.client_id(authentication_identifier)}'" ) valid = False - elif azp and azp not in ( - cls.client_id(authentication_identifier), - "account", - ): + elif not cls.is_valid_azp(authentication_identifier, azp): current_app.logger.error( f"TOKEN INVALID because azp '{azp}' does not match client id '{cls.client_id(authentication_identifier)}'" )