From 995e32308bacb826051a68cfb9335518d532f66d Mon Sep 17 00:00:00 2001 From: David Steiner Date: Thu, 27 Jul 2023 22:18:14 +0100 Subject: [PATCH] Add custom CSS and logo for Cognito hosted UI --- .gitignore | 1 + .pre-commit-config.yaml | 1 + infrastructure/custom_resources/__init__.py | 0 .../custom_resources/crhelper/__init__.py | 1 + .../custom_resources/crhelper/log_helper.py | 79 ++++ .../crhelper/resource_helper.py | 374 ++++++++++++++++++ .../custom_resources/crhelper/utils.py | 61 +++ infrastructure/custom_resources/hosted_ui.py | 79 ++++ infrastructure/hosted_ui.py | 109 +++++ infrastructure/hosted_ui_assets/cognito.css | 43 ++ infrastructure/hosted_ui_assets/logo.png | Bin 0 -> 14800 bytes infrastructure/user_pool.py | 5 + pyproject.toml | 4 +- 13 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 infrastructure/custom_resources/__init__.py create mode 100644 infrastructure/custom_resources/crhelper/__init__.py create mode 100644 infrastructure/custom_resources/crhelper/log_helper.py create mode 100644 infrastructure/custom_resources/crhelper/resource_helper.py create mode 100644 infrastructure/custom_resources/crhelper/utils.py create mode 100644 infrastructure/custom_resources/hosted_ui.py create mode 100644 infrastructure/hosted_ui.py create mode 100644 infrastructure/hosted_ui_assets/cognito.css create mode 100644 infrastructure/hosted_ui_assets/logo.png diff --git a/.gitignore b/.gitignore index c2109c6..7475d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ deploy.sh update-code.sh infrastructure/.env docker/dynamodb +**/.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8dbc95..beec5cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,7 @@ repos: rev: v1.4.1 hooks: - id: mypy + exclude: "^infrastructure/custom_resources/" - repo: local hooks: - id: rust-linting diff --git a/infrastructure/custom_resources/__init__.py b/infrastructure/custom_resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/custom_resources/crhelper/__init__.py b/infrastructure/custom_resources/crhelper/__init__.py new file mode 100644 index 0000000..971d4b7 --- /dev/null +++ b/infrastructure/custom_resources/crhelper/__init__.py @@ -0,0 +1 @@ +from crhelper.resource_helper import FAILED, SUCCESS, CfnResource diff --git a/infrastructure/custom_resources/crhelper/log_helper.py b/infrastructure/custom_resources/crhelper/log_helper.py new file mode 100644 index 0000000..d206444 --- /dev/null +++ b/infrastructure/custom_resources/crhelper/log_helper.py @@ -0,0 +1,79 @@ +from __future__ import print_function + +import json +import logging + + +def _json_formatter(obj): + """Formatter for unserialisable values.""" + return str(obj) + + +class JsonFormatter(logging.Formatter): + """AWS Lambda Logging formatter. + + Formats the log message as a JSON encoded string. If the message is a + dict it will be used directly. If the message can be parsed as JSON, then + the parse d value is used in the output record. + """ + + def __init__(self, **kwargs): + super(JsonFormatter, self).__init__() + self.format_dict = { + "timestamp": "%(asctime)s", + "level": "%(levelname)s", + "location": "%(name)s.%(funcName)s:%(lineno)d", + } + self.format_dict.update(kwargs) + self.default_json_formatter = kwargs.pop("json_default", _json_formatter) + + def format(self, record): + record_dict = record.__dict__.copy() + record_dict["asctime"] = self.formatTime(record) + + log_dict = {k: v % record_dict for k, v in self.format_dict.items() if v} + + if isinstance(record_dict["msg"], dict): + log_dict["message"] = record_dict["msg"] + else: + log_dict["message"] = record.getMessage() + + # Attempt to decode the message as JSON, if so, merge it with the + # overall message for clarity. + try: + log_dict["message"] = json.loads(log_dict["message"]) + except (TypeError, ValueError): + pass + + if record.exc_info: + # Cache the traceback text to avoid converting it multiple times + # (it's constant anyway) + # from logging.Formatter:format + if not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + + if record.exc_text: + log_dict["exception"] = record.exc_text + + json_record = json.dumps(log_dict, default=self.default_json_formatter) + + if hasattr(json_record, "decode"): # pragma: no cover + json_record = json_record.decode("utf-8") + + return json_record + + +def setup(level="DEBUG", formatter_cls=JsonFormatter, boto_level=None, **kwargs): + if formatter_cls: + for handler in logging.root.handlers: + handler.setFormatter(formatter_cls(**kwargs)) + + logging.root.setLevel(level) + + if not boto_level: + boto_level = level + + logging.getLogger("boto").setLevel(boto_level) + logging.getLogger("boto3").setLevel(boto_level) + logging.getLogger("botocore").setLevel(boto_level) + logging.getLogger("urllib3").setLevel(boto_level) diff --git a/infrastructure/custom_resources/crhelper/resource_helper.py b/infrastructure/custom_resources/crhelper/resource_helper.py new file mode 100644 index 0000000..88d6cc0 --- /dev/null +++ b/infrastructure/custom_resources/crhelper/resource_helper.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +""" +TODO: +* Async mode – take a wait condition handle as an input, increases max timeout to 12 hours +* Idempotency – If a duplicate request comes in (say there was a network error in signaling back to cfn) the subsequent + request should return the already created response, will need a persistent store of some kind... +* Functional tests +""" + +from __future__ import print_function + +import json +import logging +import os +import random +import string +import threading +from time import sleep + +import boto3 +from crhelper import log_helper +from crhelper.utils import _send_response + +logger = logging.getLogger(__name__) + +SUCCESS = "SUCCESS" +FAILED = "FAILED" + + +class CfnResource(object): + def __init__( + self, + json_logging=False, + log_level="DEBUG", + boto_level="ERROR", + polling_interval=2, + sleep_on_delete=120, + ssl_verify=None, + ): + self._sleep_on_delete = sleep_on_delete + self._create_func = None + self._update_func = None + self._delete_func = None + self._poll_create_func = None + self._poll_update_func = None + self._poll_delete_func = None + self._timer = None + self._init_failed = None + self._json_logging = json_logging + self._log_level = log_level + self._boto_level = boto_level + self._send_response = False + self._polling_interval = polling_interval + self.Status = "" + self.Reason = "" + self.PhysicalResourceId = "" + self.StackId = "" + self.RequestId = "" + self.LogicalResourceId = "" + self.Data = {} + self.NoEcho = False + self._event = {} + self._context = None + self._response_url = "" + self._sam_local = os.getenv("AWS_SAM_LOCAL") + self._region = os.getenv("AWS_REGION") + self._ssl_verify = ssl_verify + try: + if not self._sam_local: + self._lambda_client = boto3.client( + "lambda", region_name=self._region, verify=self._ssl_verify + ) + self._events_client = boto3.client( + "events", region_name=self._region, verify=self._ssl_verify + ) + self._logs_client = boto3.client( + "logs", region_name=self._region, verify=self._ssl_verify + ) + if json_logging: + log_helper.setup( + log_level, boto_level=boto_level, RequestType="ContainerInit" + ) + else: + log_helper.setup(log_level, formatter_cls=None, boto_level=boto_level) + except Exception as e: + logger.error(e, exc_info=True) + self.init_failure(e) + + def __call__(self, event, context): + try: + self._log_setup(event, context) + logger.debug(event) + if not self._crhelper_init(event, context): + return + # Check for polling functions + if self._poll_enabled() and self._sam_local: + logger.info( + "Skipping poller functionality, as this is a local invocation" + ) + elif self._poll_enabled(): + self._polling_init(event) + # If polling is not enabled, then we should respond + else: + logger.debug("enabling send_response") + self._send_response = True + logger.debug("_send_response: %s" % self._send_response) + if self._send_response: + if self.RequestType == "Delete": + self._wait_for_cwlogs() + self._cfn_response(event) + except Exception as e: + logger.error(e, exc_info=True) + self._send(FAILED, str(e)) + finally: + if self._timer: + self._timer.cancel() + + def _wait_for_cwlogs(self, sleep=sleep): + time_left = int(self._context.get_remaining_time_in_millis() / 1000) - 15 + sleep_time = 0 + + if time_left > self._sleep_on_delete: + sleep_time = self._sleep_on_delete + + if sleep_time > 1: + sleep(sleep_time) + + def _log_setup(self, event, context): + if self._json_logging: + log_helper.setup( + self._log_level, + boto_level=self._boto_level, + RequestType=event["RequestType"], + StackId=event["StackId"], + RequestId=event["RequestId"], + LogicalResourceId=event["LogicalResourceId"], + aws_request_id=context.aws_request_id, + ) + else: + log_helper.setup( + self._log_level, boto_level=self._boto_level, formatter_cls=None + ) + + def _crhelper_init(self, event, context): + self._send_response = False + self.Status = SUCCESS + self.Reason = "" + self.PhysicalResourceId = "" + self.StackId = event["StackId"] + self.RequestId = event["RequestId"] + self.LogicalResourceId = event["LogicalResourceId"] + self.Data = {} + if "CrHelperData" in event.keys(): + self.Data = event["CrHelperData"] + self.RequestType = event["RequestType"] + self._event = event + self._context = context + self._response_url = event["ResponseURL"] + if self._timer: + self._timer.cancel() + if self._init_failed: + self._send(FAILED, str(self._init_failed)) + return False + self._set_timeout() + self._wrap_function(self._get_func()) + return True + + def _polling_init(self, event): + # Setup polling on initial request + logger.debug("pid1: %s" % self.PhysicalResourceId) + if "CrHelperPoll" not in event.keys() and self.Status != FAILED: + logger.info("Setting up polling") + self.Data["PhysicalResourceId"] = self.PhysicalResourceId + self._setup_polling() + self.PhysicalResourceId = None + logger.debug("pid2: %s" % self.PhysicalResourceId) + # if physical id is set, or there was a failure then we're done + logger.debug("pid3: %s" % self.PhysicalResourceId) + if self.PhysicalResourceId or self.Status == FAILED: + logger.info("Polling complete, removing cwe schedule") + self._remove_polling() + self._send_response = True + + def generate_physical_id(self, event): + return "_".join( + [ + event["StackId"].split("/")[1], + event["LogicalResourceId"], + self._rand_string(8), + ] + ) + + def _cfn_response(self, event): + # Use existing PhysicalResourceId if it's in the event and no ID was set + if not self.PhysicalResourceId and "PhysicalResourceId" in event.keys(): + logger.info("PhysicalResourceId present in event, Using that for response") + self.PhysicalResourceId = event["PhysicalResourceId"] + # Generate a physical id if none is provided + elif not self.PhysicalResourceId or self.PhysicalResourceId is True: + logger.info("No physical resource id returned, generating one...") + self.PhysicalResourceId = self.generate_physical_id(event) + self._send() + + def _poll_enabled(self): + return getattr(self, "_poll_{}_func".format(self._event["RequestType"].lower())) + + def create(self, func): + self._create_func = func + return func + + def update(self, func): + self._update_func = func + return func + + def delete(self, func): + self._delete_func = func + return func + + def poll_create(self, func): + self._poll_create_func = func + return func + + def poll_update(self, func): + self._poll_update_func = func + return func + + def poll_delete(self, func): + self._poll_delete_func = func + return func + + def _wrap_function(self, func): + try: + self.PhysicalResourceId = func(self._event, self._context) if func else "" + except Exception as e: + logger.error(str(e), exc_info=True) + self.Reason = str(e) + self.Status = FAILED + + def _timeout(self): + logger.error("Execution is about to time out, sending failure message") + self._send(FAILED, "Execution timed out") + + def _set_timeout(self): + self._timer = threading.Timer( + (self._context.get_remaining_time_in_millis() / 1000.00) - 0.5, + self._timeout, + ) + self._timer.start() + + def _get_func(self): + request_type = "_{}_func" + if "CrHelperPoll" in self._event.keys(): + request_type = "_poll" + request_type + return getattr(self, request_type.format(self._event["RequestType"].lower())) + + def _send(self, status=None, reason="", send_response=_send_response): + if len(str(str(self.Reason))) > 256: + self.Reason = ( + "ERROR: (truncated) " + str(self.Reason)[len(str(self.Reason)) - 240 :] + ) + if len(str(reason)) > 256: + reason = "ERROR: (truncated) " + str(reason)[len(str(reason)) - 240 :] + response_body = { + "Status": self.Status, + "PhysicalResourceId": str(self.PhysicalResourceId), + "StackId": self.StackId, + "RequestId": self.RequestId, + "LogicalResourceId": self.LogicalResourceId, + "Reason": str(self.Reason), + "Data": self.Data, + "NoEcho": self.NoEcho, + } + if status: + response_body.update({"Status": status, "Reason": reason}) + send_response(self._response_url, response_body, self._ssl_verify) + + def init_failure(self, error): + self._init_failed = error + logger.error(str(error), exc_info=True) + + def _cleanup_response(self): + for k in ["CrHelperPoll", "CrHelperPermission", "CrHelperRule"]: + if k in self.Data.keys(): + del self.Data[k] + + @staticmethod + def _rand_string(l): + return "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(l) + ) + + def _add_permission(self, rule_arn): + sid = self._event["LogicalResourceId"] + self._rand_string(8) + self._lambda_client.add_permission( + FunctionName=self._context.function_name, + StatementId=sid, + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn, + ) + return sid + + def _put_rule(self): + schedule_unit = "minutes" if self._polling_interval != 1 else "minute" + response = self._events_client.put_rule( + Name=self._event["LogicalResourceId"] + self._rand_string(8), + ScheduleExpression="rate({} {})".format( + self._polling_interval, schedule_unit + ), + State="ENABLED", + ) + return response["RuleArn"] + + def _put_targets(self, func_name): + region = self._event["CrHelperRule"].split(":")[3] + account_id = self._event["CrHelperRule"].split(":")[4] + partition = self._event["CrHelperRule"].split(":")[1] + rule_name = self._event["CrHelperRule"].split("/")[1] + logger.debug(self._event) + self._events_client.put_targets( + Rule=rule_name, + Targets=[ + { + "Id": "1", + "Arn": "arn:%s:lambda:%s:%s:function:%s" + % (partition, region, account_id, func_name), + "Input": json.dumps(self._event), + } + ], + ) + + def _remove_targets(self, rule_arn): + self._events_client.remove_targets(Rule=rule_arn.split("/")[1], Ids=["1"]) + + def _remove_permission(self, sid): + self._lambda_client.remove_permission( + FunctionName=self._context.function_name, StatementId=sid + ) + + def _delete_rule(self, rule_arn): + self._events_client.delete_rule(Name=rule_arn.split("/")[1]) + + def _setup_polling(self): + self._event["CrHelperData"] = self.Data + self._event["CrHelperPoll"] = True + self._event["CrHelperRule"] = self._put_rule() + self._event["CrHelperPermission"] = self._add_permission( + self._event["CrHelperRule"] + ) + self._put_targets(self._context.function_name) + + def _remove_polling(self): + if "CrHelperData" in self._event.keys(): + self._event.pop("CrHelperData") + if "PhysicalResourceId" in self.Data.keys(): + self.Data.pop("PhysicalResourceId") + if "CrHelperRule" in self._event.keys(): + self._remove_targets(self._event["CrHelperRule"]) + else: + logger.error( + "Cannot remove CloudWatch events rule, Rule arn not available in event" + ) + if "CrHelperPermission" in self._event.keys(): + self._remove_permission(self._event["CrHelperPermission"]) + else: + logger.error( + "Cannot remove lambda events permission, permission id not available in event" + ) + if "CrHelperRule" in self._event.keys(): + self._delete_rule(self._event["CrHelperRule"]) + else: + logger.error( + "Cannot remove CloudWatch events target, Rule arn not available in event" + ) diff --git a/infrastructure/custom_resources/crhelper/utils.py b/infrastructure/custom_resources/crhelper/utils.py new file mode 100644 index 0000000..3f05d49 --- /dev/null +++ b/infrastructure/custom_resources/crhelper/utils.py @@ -0,0 +1,61 @@ +from __future__ import print_function + +import json +import logging as logging +import ssl +import time +from http.client import HTTPSConnection +from os import path +from typing import AnyStr, Union +from urllib.parse import urlsplit, urlunsplit + +logger = logging.getLogger(__name__) + + +def _send_response( + response_url: AnyStr, response_body: AnyStr, ssl_verify: Union[bool, AnyStr] = None +): + try: + json_response_body = json.dumps(response_body) + except Exception as e: + msg = "Failed to convert response to json: {}".format(str(e)) + logger.error(msg, exc_info=True) + response_body = {"Status": "FAILED", "Data": {}, "Reason": msg} + json_response_body = json.dumps(response_body) + logger.debug("CFN response URL: {}".format(response_url)) + logger.debug(json_response_body) + headers = {"content-type": "", "content-length": str(len(json_response_body))} + split_url = urlsplit(response_url) + host = split_url.netloc + url = urlunsplit(("", "", *split_url[2:])) + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + if isinstance(ssl_verify, str): + if path.exists(ssl_verify): + ctx.load_verify_locations(cafile=ssl_verify) + else: + logger.warning( + "Cert path {0} does not exist!. Falling back to using system cafile.".format( + ssl_verify + ) + ) + if ssl_verify is False: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + # If ssl_verify is True or None dont modify the context in any way. + while True: + try: + connection = HTTPSConnection(host, context=ctx) + connection.request( + method="PUT", url=url, body=json_response_body, headers=headers + ) + response = connection.getresponse() + logger.info( + "CloudFormation returned status code: {}".format(response.reason) + ) + break + except Exception as e: + logger.error( + "Unexpected failure sending response to CloudFormation {}".format(e), + exc_info=True, + ) + time.sleep(5) diff --git a/infrastructure/custom_resources/hosted_ui.py b/infrastructure/custom_resources/hosted_ui.py new file mode 100644 index 0000000..20a8665 --- /dev/null +++ b/infrastructure/custom_resources/hosted_ui.py @@ -0,0 +1,79 @@ +"""Custom resource to update the hosted UI in Cognito. + +Mostly taken from: https://python.plainenglish.io/configuring-cognitos-hosted-ui-with-a-custom-resource-in-cdk-python-9f2fde423f95 +""" +import logging +import os + +import boto3 +from crhelper import CfnResource + +logger = logging.getLogger(__name__) + +helper = CfnResource( + json_logging=False, + log_level="DEBUG", + boto_level="CRITICAL", + sleep_on_delete=120, + ssl_verify=None, +) +cognito = boto3.client("cognito-idp") +s3 = boto3.resource("s3") + +image = None +css = None + +try: + css = s3.Object(os.environ["ASSET_BUCKET"], os.environ["CSS_KEY"]).get() + image = s3.Object(os.environ["ASSET_BUCKET"], os.environ["IMAGE_FILE_KEY"]).get() +except Exception as e: + helper.init_failure(e) + + +def set_ui_customizations(): + try: + css_data = css["Body"].read().decode("utf-8") + image_data = image["Body"].read() + cognito.set_ui_customization( + UserPoolId=os.environ["USER_POOL_ID"], + ClientId=os.environ["CLIENT_ID"], + CSS=css_data, + ImageFile=image_data, + ) + logger.info("Updated Cognito Hosted UI") + except Exception as e: + logger.exception(e) + raise ValueError( + "An error occurred when attempting to set the UI customizations for the user pool client. See the CloudWatch logs for details" + ) + + +@helper.create +def create(event, context): + logger.info("Got Create") + set_ui_customizations() + return None + + +@helper.update +def update(event, context): + logger.info("Got Update") + set_ui_customizations() + return None + + +@helper.delete +def delete(event, context): + logger.info("Got Delete") + + +@helper.poll_create +def poll_create(event, context): + logger.info("Got create poll") + # Return a resource id or True to indicate that creation is complete. + # If True is returned an id will be generated + return True + + +def handler(event, context): + helper(event, context) diff --git a/infrastructure/hosted_ui.py b/infrastructure/hosted_ui.py new file mode 100644 index 0000000..fbe0d31 --- /dev/null +++ b/infrastructure/hosted_ui.py @@ -0,0 +1,109 @@ +"""Hosted UI for Cognito.""" +import pathlib + +import aws_cdk.aws_iam as iam +import aws_cdk.custom_resources as cr +from aws_cdk import CustomResource +from aws_cdk.aws_cognito import UserPool, UserPoolClient +from aws_cdk.aws_lambda import Architecture, Code, Function, Runtime +from aws_cdk.aws_logs import RetentionDays +from aws_cdk.aws_s3_assets import Asset +from constructs import Construct + +CODE_PATH = pathlib.Path(__file__).parent / "custom_resources" +ASSETS_PATH = pathlib.Path(__file__).parent / "hosted_ui_assets" + + +class HostedUI(Construct): + """Hosted UI customisations for Cognito.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + user_pool: UserPool, + client: UserPoolClient, + ) -> None: + """Create the resources for hosted UI customisations.""" + super().__init__(scope, construct_id) + + logo = self._create_logo_asset() + css = self._create_css_asset() + custom_function = self._create_custom_function(css, logo, user_pool, client) + provider = self._create_provider(custom_function) + + CustomResource( + self, + "CustomResource", + service_token=provider.service_token, + properties={"css": css.s3_object_key, "logo": logo.s3_object_key}, + ) + + def _create_provider(self, custom_function: Function) -> cr.Provider: + role = iam.Role( + self, + "CognitoUiProviderRole", + managed_policies=[ + iam.ManagedPolicy.from_aws_managed_policy_name( + managed_policy_name="AWSLambdaExecute" + ) + ], + assumed_by=iam.ServicePrincipal(service="lambda.amazonaws.com"), + ) + + return cr.Provider( + self, + "CognitoUiProvider", + on_event_handler=custom_function, + log_retention=RetentionDays.ONE_WEEK, # default is INFINITE + role=role, + ) + + def _create_logo_asset(self) -> Asset: + return Asset( + self, + "CognitoHostedUiLogo", + path=(ASSETS_PATH / "logo.png").as_posix(), + ) + + def _create_css_asset(self) -> Asset: + return Asset( + self, + "CognitoHostedUiCss", + path=(ASSETS_PATH / "cognito.css").as_posix(), + ) + + def _create_custom_function( + self, css: Asset, logo: Asset, user_pool: UserPool, client: UserPoolClient + ) -> Function: + custom_function = Function( + self, + "CognitoSetupUiEventHandler", + environment={ + "ASSET_BUCKET": logo.s3_bucket_name, + "IMAGE_FILE_KEY": logo.s3_object_key, + "CSS_KEY": css.s3_object_key, + "USER_POOL_ID": user_pool.user_pool_id, + "CLIENT_ID": client.user_pool_client_id, + }, + runtime=Runtime.PYTHON_3_9, + architecture=Architecture.ARM_64, + handler="hosted_ui.handler", + code=Code.from_asset(CODE_PATH.as_posix()), + dead_letter_queue_enabled=True, + ) + + logo.grant_read(custom_function) + css.grant_read(custom_function) + + custom_function.add_to_role_policy( + statement=iam.PolicyStatement( + actions=[ + "cognito-idp:SetUICustomization", + ], + effect=iam.Effect.ALLOW, + resources=[user_pool.user_pool_arn], + ) + ) + + return custom_function diff --git a/infrastructure/hosted_ui_assets/cognito.css b/infrastructure/hosted_ui_assets/cognito.css new file mode 100644 index 0000000..c845444 --- /dev/null +++ b/infrastructure/hosted_ui_assets/cognito.css @@ -0,0 +1,43 @@ +.logo-customizable { + max-width: 100%; + max-height: 100%; +} + +.banner-customizable { + padding: 10px 0px 10px 0px; + background-color: #5e6ca9; +} + +.submitButton-customizable { + font-size: 14px; + font-weight: bold; + margin: 20px 0px 10px 0px; + height: 40px; + width: 100%; + color: #fff; + background-color: #5e6ca9; +} + +.idpButton-customizable { + font-size: 14px; + font-weight: bold; + margin: 20px 0px 10px 0px; + height: 40px; + width: 100%; + color: #fff; + background-color: #5e6ca9; +} + +.submitButton-customizable:hover { + color: #fff; + background-color: gray; +} + +.idpButton-customizable:hover { + color: #fff; + background-color: gray; +} + +.inputField-customizable:focus { + border-color: #5e6ca9; +} diff --git a/infrastructure/hosted_ui_assets/logo.png b/infrastructure/hosted_ui_assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9d33fcbaea136808dd1b244c32851805a4003402 GIT binary patch literal 14800 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 HngsnHMu!