Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,17 @@ Beyond configuration, customization of Keycloak (e.g. a custom Identity Provider
> [!TIP]
> See the theme section in the [Server Developer Guide](https://www.keycloak.org/docs/latest/server_development/#_themes) for more details about how to create custom themes.

### SES Relay
The AWS account that includes the SES `openveda.cloud` identity does not permit creating SMTP credentials for AWS SES for security reasons. However, Keycloak expects to talk to an SMTP server for sending transactional emails such as verification, password reset, and notification messages.

To bridge this gap, we deploy a small SMTP relay service as an ECS Fargate service into the same VPC as Keycloak:

- **Keycloak → SMTP Relay**: Keycloak is configured to use the relay’s internal NLB endpoint on port `10025` as its SMTP server without SMTP authentication.
- **SMTP Relay → SES**: The relay authenticates to AWS using IAM (task role) and delivers messages to SES using the SES API (for example, `ses:SendEmail`, `ses:SendRawEmail`), avoiding the need for SES SMTP credentials.
- **Network Isolation**: The Network Load Balancer for the relay is internal-only; access is restricted to the VPC CIDR (services inside the VPC only).

The relay itself is based on [`loopingz/smtp-relay`](https://github.com/loopingz/smtp-relay/) project, configured to accept SMTP from Keycloak and forward mail to AWS SES.

## Useful commands

- `npm run build` compile typescript to js
Expand Down
1 change: 1 addition & 0 deletions cdk/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
keycloak_app_dir=settings.keycloak_app_dir.as_posix(),
keycloak_config_cli_version=settings.keycloak_config_cli_version,
keycloak_config_cli_app_dir=settings.keycloak_config_cli_app_dir.as_posix(),
ses_relay_app_dir=settings.ses_relay_app_dir.as_posix(),
keycloak_send_email_addresses=send_email_addresses,
idp_oauth_client_secrets=idp_oauth_client_secrets,
private_oauth_clients=private_oauth_clients,
Expand Down
9 changes: 9 additions & 0 deletions cdk/lib/keycloak/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .service import KeycloakService
from .config import KeycloakConfig
from .url import KeycloakUrl
from lib.sesrelay import SesRelayStack


class KeycloakStack(Stack):
Expand All @@ -27,6 +28,7 @@ def __init__(
keycloak_app_dir: str,
keycloak_config_cli_version: str,
keycloak_config_cli_app_dir: str,
ses_relay_app_dir: str,
idp_oauth_client_secrets: dict,
private_oauth_clients: list,
application_role_arns: dict[str, list[str]],
Expand Down Expand Up @@ -86,6 +88,13 @@ def __init__(
stage=stage,
)

ses_relay_stack = SesRelayStack(
self,
"ses-relay",
vpc=vpc,
ses_relay_app_dir=ses_relay_app_dir,
)

if configure_route53:
KeycloakUrl(
self,
Expand Down
5 changes: 5 additions & 0 deletions cdk/lib/sesrelay/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM loopingz/smtp-relay:v2.2.5

COPY aws-smtp-relay-custom.jsonc configs/aws-smtp-relay-custom.jsonc

CMD ["configs/aws-smtp-relay-custom.jsonc"]
3 changes: 3 additions & 0 deletions cdk/lib/sesrelay/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .stack import SesRelayStack

__all__ = [SesRelayStack]
32 changes: 32 additions & 0 deletions cdk/lib/sesrelay/aws-smtp-relay-custom.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Replace my previous project aws-smtp-relay
{
"$schema": "https://raw.githubusercontent.com/loopingz/smtp-relay/main/config.schema.json",
"flows": {
"localhost": {
"filters": [
// Allow any ip to use the SMTP
{
"type": "whitelist",
"ips": ["regexp:.*"]
}
],
"outputs": [
{
"type": "aws",
"ses": true,
"sendRawEmailOptions": {
"SourceArn": "arn:aws:ses:us-west-2:444055461661:identity/openveda.cloud",
"FromArn": "arn:aws:ses:us-west-2:444055461661:identity/openveda.cloud",
"ReturnPathArn": "arn:aws:ses:us-west-2:444055461661:identity/openveda.cloud"
}
}
]
}
},
"options": {
"disableReverseLookup": false,
"hideSTARTTLS": true,
// Do not require auth
"authOptional": true
}
}
97 changes: 97 additions & 0 deletions cdk/lib/sesrelay/stack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Architecture adapted from https://github.com/aws-samples/fargate-ses-relay

from typing import Optional

from aws_cdk import (
NestedStack,
aws_ec2 as ec2,
aws_ecs as ecs,
aws_ecs_patterns as ecs_patterns,
aws_elasticloadbalancingv2 as elbv2,
aws_iam as iam,
aws_ecr_assets as ecr_assets,
CfnOutput,
)
from constructs import Construct

class SesRelayStack(NestedStack):
def __init__(
self,
scope: Construct,
construct_id: str,
*,
vpc: ec2.IVpc,
ses_relay_app_dir: Optional[str] = None,
**kwargs,
) -> None:
super().__init__(scope, construct_id, **kwargs)


cluster = ecs.Cluster(self, "Cluster",
cluster_name="veda-keycloak-ses-relay",
vpc=vpc,
)

task_role = iam.Role(self, "TaskRole",
role_name="SesRelayTaskRole",
assumed_by=iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
inline_policies={"SesRelayPolicy": iam.PolicyDocument(
statements=[iam.PolicyStatement(
actions=["ses:SendRawEmail","ses:SendEmail"],
resources=["*"],
)]
)}
)

task_image_options=ecs_patterns.NetworkLoadBalancedTaskImageOptions(
image=ecs.ContainerImage.from_asset(
directory=ses_relay_app_dir,
platform=ecr_assets.Platform.LINUX_AMD64,
),
container_name="SesRelayContainer",
container_port=10025,
task_role=task_role,
)

nlb_sg=ec2.SecurityGroup(
scope=self,
id="NLB-SG",
vpc=vpc,
description="Allow access to SES Relay",
allow_all_outbound=True,
)

cidr = vpc.vpc_cidr_block # Allow access from the VPC
nlb_sg.add_ingress_rule(
peer = ec2.Peer.ipv4(cidr),
connection = ec2.Port.tcp(10025),
description="Allow from " + cidr
)

nlb = elbv2.NetworkLoadBalancer(
scope=self,
id='FG-NLB',
vpc=vpc,
security_groups=[nlb_sg],
internet_facing=False,
)

service = ecs_patterns.NetworkLoadBalancedFargateService(
self, "FargateService",
service_name="veda-keycloak-ses-relay",
cluster=cluster,
task_image_options=task_image_options,
desired_count=1,
memory_limit_mib=2048,
cpu=1024,
listener_port=10025,
load_balancer=nlb,
)

service.service.connections.security_groups[0].add_ingress_rule(
peer = nlb_sg,
connection = ec2.Port.tcp(10025),
description="Allow from NLB Security Group"
)

CfnOutput(self, "NLBDNS", key="NLBDNS", value=nlb.load_balancer_dns_name)
1 change: 1 addition & 0 deletions cdk/lib/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Settings(BaseSettings):
stage: str = "dev"
keycloak_version: str = "26.0.5"
keycloak_app_dir: DirectoryPath = DirectoryPath("keycloak")
ses_relay_app_dir: DirectoryPath = DirectoryPath("cdk/lib/sesrelay")
keycloak_config_cli_version: str = "latest-26"
keycloak_config_cli_app_dir: DirectoryPath = DirectoryPath("keycloak-config-cli")
rds_snapshot_identifier: Optional[str] = Field(
Expand Down