Repository: moov-io/achgateway
Pull request #251: Add Support for AWS MSK IAM authentication
Author: login: myronjhicks
Created: 2025-02-26T19:55:59Z | Merged: 2025-03-07T21:11:19Z

# Changes

- Integrated AWS MSK IAM authentication using the aws-msk-iam-sasl-signer-go library based on the blog post: [Amazon MSK IAM authentication now supports all programming languages](https://aws.amazon.com/blogs/big-data/amazon-msk-iam-authentication-now-supports-all-programming-languages/)
- Introduced new configuration options (AWSRegion, AWSProfile, AWSRoleARN, AWSSessionName) to support different IAM authentication methods.
- Maintained backward compatibility by defaulting to PLAIN authentication when MSK IAM is not required.
- only adds 1 net new dependency to achgateway. 

# Why Are Changes Being Made
AWS MSK IAM authentication is now a standard practice for securing Kafka access on AWS, and this change allows clients to support MSK authentication seamlessly. While `AWS_MSK_IAM` is not a standard [SASLMechanism](https://docs.confluent.io/platform/current/clients/confluent-kafka-dotnet/_site/api/Confluent.Kafka.SaslMechanism.html) it is used in configuring [kafka auth with aws msk](https://github.com/aws/aws-msk-iam-auth?tab=readme-ov-file#configuring-a-kafka-client-to-use-aws-iam-with-aws_msk_iam-mechanism) directly on client machines that run kafka and implementers should be somewhat familiar with this convention 


Diff:
diff --git a/docs/config.md b/docs/config.md
index bd1dd68f..63afb8af 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -72,6 +72,13 @@ ACHGateway:
       Topic: <string>
       TLS: <boolean>
       AutoCommit: <boolean>
+      [ SASLMechanism: <string> | default = "PLAIN" ]
+      Provider:
+        AWS:
+          [ Region: <string> | default = "" ]
+          [ Profile: <string> | default = "" ]
+          [ RoleARN: <string> | default = "" ]
+          [ SessionName: <string> | default = "" ]
       Transform:
         Encoding:
           [ Base64: <boolean> | default = false ]
@@ -156,6 +163,13 @@ ACHGateway:
         Topic: <string>
         [ TLS: <boolean> | default = false ]
         [ AutoCommit: <boolean> | default = false ]
+        [ SASLMechanism: <string> | default = "PLAIN" ]
+        Provider:
+          AWS:
+            [ Region: <string> | default = "" ]
+            [ Profile: <string> | default = "" ]
+            [ RoleARN: <string> | default = "" ]
+            [ SessionName: <string> | default = "" ]
     Webhook:
       [ Endpoint: <string> | default = "" ]
 ```
diff --git a/go.mod b/go.mod
index 43b6ff78..2c6f8c09 100644
--- a/go.mod
+++ b/go.mod
@@ -67,6 +67,7 @@ require (
 	github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.24.1 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
 	github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
+	github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1
 	github.com/aws/aws-sdk-go v1.50.36 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.25.3 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 // indirect
diff --git a/internal/kafka/kafka.go b/internal/kafka/kafka.go
index 018d1405..7250abd3 100644
--- a/internal/kafka/kafka.go
+++ b/internal/kafka/kafka.go
@@ -1,12 +1,15 @@
 package kafka
 
 import (
+	"context"
+	"crypto/tls"
 	"time"
 
 	"github.com/moov-io/achgateway/internal/service"
 	"github.com/moov-io/base/log"
 
 	"github.com/Shopify/sarama"
+	"github.com/aws/aws-msk-iam-sasl-signer-go/signer"
 	"gocloud.dev/pubsub"
 	"gocloud.dev/pubsub/kafkapubsub"
 )
@@ -15,13 +18,58 @@ var (
 	minKafkaVersion = sarama.V2_6_0_0
 )
 
+type MSKAccessTokenProvider struct {
+	Region      string
+	Profile     string
+	RoleARN     string
+	SessionName string
+}
+
+func (m *MSKAccessTokenProvider) Token() (*sarama.AccessToken, error) {
+	var token string
+	var err error
+
+	// Choose the correct AWS authentication method
+	switch {
+	case m.Profile != "":
+		token, _, err = signer.GenerateAuthTokenFromProfile(context.TODO(), m.Region, m.Profile)
+	case m.RoleARN != "":
+		token, _, err = signer.GenerateAuthTokenFromRole(context.TODO(), m.Region, m.RoleARN, m.SessionName)
+	default:
+		token, _, err = signer.GenerateAuthToken(context.TODO(), m.Region)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+	return &sarama.AccessToken{Token: token}, nil
+}
+
 func OpenTopic(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Topic, error) {
 	config := kafkapubsub.MinimalConfig()
 	config.Version = minKafkaVersion
 	config.Net.TLS.Enable = cfg.TLS
 
 	config.Net.SASL.Enable = cfg.Key != ""
-	config.Net.SASL.Mechanism = sarama.SASLMechanism("PLAIN")
+
+	switch cfg.SASLMechanism {
+	case "AWS_MSK_IAM":
+		if cfg.Provider != nil && cfg.Provider.AWS != nil {
+			config.Net.SASL.TokenProvider = &MSKAccessTokenProvider{
+				Region:      cfg.Provider.AWS.Region,
+				Profile:     cfg.Provider.AWS.Profile,
+				RoleARN:     cfg.Provider.AWS.RoleARN,
+				SessionName: cfg.Provider.AWS.SessionName,
+			}
+		}
+		config.Net.SASL.Mechanism = sarama.SASLTypeOAuth
+		config.Net.TLS.Enable = true
+		config.Net.TLS.Config = &tls.Config{}
+
+	default:
+		config.Net.SASL.Mechanism = sarama.SASLTypePlaintext
+	}
+
 	config.Net.SASL.User = cfg.Key
 	config.Net.SASL.Password = cfg.Secret
 
@@ -33,6 +81,7 @@ func OpenTopic(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Topic, erro
 		Set("tls", log.Bool(cfg.TLS)).
 		Set("group", log.String(cfg.Group)).
 		Set("sasl.enable", log.Bool(config.Net.SASL.Enable)).
+		Set("sasl.mechanism", log.String(string(config.Net.SASL.Mechanism))).
 		Set("sasl.user", log.String(cfg.Key)).
 		Set("topic", log.String(cfg.Topic)).
 		Log("opening kafka topic")
@@ -46,7 +95,25 @@ func OpenSubscription(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Subs
 	config.Net.TLS.Enable = cfg.TLS
 
 	config.Net.SASL.Enable = cfg.Key != ""
-	config.Net.SASL.Mechanism = sarama.SASLMechanism("PLAIN")
+	// Default to PLAIN if no SASL mechanism is specified
+	switch cfg.SASLMechanism {
+	case "AWS_MSK_IAM":
+		if cfg.Provider != nil && cfg.Provider.AWS != nil {
+			config.Net.SASL.TokenProvider = &MSKAccessTokenProvider{
+				Region:      cfg.Provider.AWS.Region,
+				Profile:     cfg.Provider.AWS.Profile,
+				RoleARN:     cfg.Provider.AWS.RoleARN,
+				SessionName: cfg.Provider.AWS.SessionName,
+			}
+		}
+		config.Net.SASL.Mechanism = sarama.SASLTypeOAuth
+		config.Net.TLS.Enable = true
+		config.Net.TLS.Config = &tls.Config{}
+
+	default:
+		config.Net.SASL.Mechanism = sarama.SASLTypePlaintext
+	}
+
 	config.Net.SASL.User = cfg.Key
 	config.Net.SASL.Password = cfg.Secret
 
@@ -62,6 +129,7 @@ func OpenSubscription(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Subs
 		Set("tls", log.Bool(cfg.TLS)).
 		Set("group", log.String(cfg.Group)).
 		Set("sasl.enable", log.Bool(config.Net.SASL.Enable)).
+		Set("sasl.mechanism", log.String(string(config.Net.SASL.Mechanism))).
 		Set("sasl.user", log.String(cfg.Key)).
 		Set("topic", log.String(cfg.Topic)).
 		Log("setting up kafka subscription")
diff --git a/internal/service/model_inbound.go b/internal/service/model_inbound.go
index 2d3d1182..24c65754 100644
--- a/internal/service/model_inbound.go
+++ b/internal/service/model_inbound.go
@@ -66,6 +66,20 @@ func (cfg *InMemory) Validate() error {
 	return nil
 }
 
+// AWSConfig holds authentication settings for AWS MSK clusters.
+// Used when KafkaConfig.SASLMechanism is set to AWS_MSK_IAM.
+type AWSConfig struct {
+	Region      string
+	Profile     string
+	RoleARN     string
+	SessionName string
+}
+
+// KafkaProvider represents the cloud provider settings for Kafka
+type KafkaProvider struct {
+	AWS *AWSConfig
+}
+
 type KafkaConfig struct {
 	Brokers []string
 	Key     string
@@ -77,7 +91,10 @@ type KafkaConfig struct {
 	// AutoCommit in Sarama refers to "automated publishing of consumer offsets
 	// to the broker" rather than a Kafka broker's meaning of "commit consumer
 	// offsets on read" which leads to "at-most-once" delivery.
-	AutoCommit bool
+	AutoCommit    bool
+	SASLMechanism string
+
+	Provider *KafkaProvider
 
 	Consumer KafkaConsumerConfig
 	Producer KafkaProducerConfig


Reviews:
user: login: adamdecaf
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T21:30:13Z
user: login: myronjhicks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T21:55:03Z
user: login: adamdecaf
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T21:00:51Z
user: login: adamdecaf
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T21:01:17Z
user: login: myronjhicks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T21:36:04Z
user: login: myronjhicks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T21:37:11Z
user: login: adamdecaf
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T20:48:42Z
user: login: adamdecaf
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-07T17:57:47Z

Review comments:
diff_hunk: @@ -1,12 +1,15 @@
 package kafka
 
 import (
+	"context"
+	"crypto/tls"
 	"time"
 
 	"github.com/moov-io/achgateway/internal/service"
 	"github.com/moov-io/base/log"
 
 	"github.com/Shopify/sarama"
+	"github.com/aws/aws-msk-iam-sasl-signer-go/signer"
path: internal/kafka/kafka.go
user: login: adamdecaf
body: Is this an AWS specific thing -- could other kafka hosts use it? 
created_at: 2025-02-27T21:30:13Z
author_association: MEMBER
diff_hunk: @@ -1,12 +1,15 @@
 package kafka
 
 import (
+	"context"
+	"crypto/tls"
 	"time"
 
 	"github.com/moov-io/achgateway/internal/service"
 	"github.com/moov-io/base/log"
 
 	"github.com/Shopify/sarama"
+	"github.com/aws/aws-msk-iam-sasl-signer-go/signer"
path: internal/kafka/kafka.go
user: login: myronjhicks
body: Yeah I think pretty specific to aws. I found it here: https://aws.amazon.com/blogs/big-data/amazon-msk-iam-authentication-now-supports-all-programming-languages/ - just using a dev container to confirm it's functionality to figure out a path forward for adoption 
created_at: 2025-02-27T21:55:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,8 +79,10 @@ func OpenTopic(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Topic, erro
 		Set("tls", log.Bool(cfg.TLS)).
 		Set("group", log.String(cfg.Group)).
 		Set("sasl.enable", log.Bool(config.Net.SASL.Enable)).
+		Set("sasl.mechanism", log.String(string(config.Net.SASL.Mechanism))).
 		Set("sasl.user", log.String(cfg.Key)).
 		Set("topic", log.String(cfg.Topic)).
+		Set("aws.region", log.String(cfg.AWSRegion)).
path: internal/kafka/kafka.go
user: login: adamdecaf
body: AWS is optional, so I would only add it to the logger if there's a value. 
created_at: 2025-03-04T21:00:50Z
author_association: MEMBER
diff_hunk: @@ -46,7 +94,23 @@ func OpenSubscription(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Subs
 	config.Net.TLS.Enable = cfg.TLS
 
 	config.Net.SASL.Enable = cfg.Key != ""
-	config.Net.SASL.Mechanism = sarama.SASLMechanism("PLAIN")
+	// Default to PLAIN if no SASL mechanism is specified
+	switch cfg.SASLMechanism {
+	case "AWS_MSK_IAM":
+		config.Net.SASL.Mechanism = sarama.SASLTypeOAuth
+		config.Net.SASL.TokenProvider = &MSKAccessTokenProvider{
+			Region:      cfg.AWSRegion,
+			Profile:     cfg.AWSProfile,
+			RoleARN:     cfg.AWSRoleARN,
+			SessionName: cfg.AWSSessionName,
+		}
+		config.Net.TLS.Enable = true
+		config.Net.TLS.Config = &tls.Config{}
path: internal/kafka/kafka.go
user: login: adamdecaf
body: We need an empty TLS config? Why not pass cfg.TLS? 
created_at: 2025-03-04T21:01:17Z
author_association: MEMBER
diff_hunk: @@ -46,7 +94,23 @@ func OpenSubscription(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Subs
 	config.Net.TLS.Enable = cfg.TLS
 
 	config.Net.SASL.Enable = cfg.Key != ""
-	config.Net.SASL.Mechanism = sarama.SASLMechanism("PLAIN")
+	// Default to PLAIN if no SASL mechanism is specified
+	switch cfg.SASLMechanism {
+	case "AWS_MSK_IAM":
+		config.Net.SASL.Mechanism = sarama.SASLTypeOAuth
+		config.Net.SASL.TokenProvider = &MSKAccessTokenProvider{
+			Region:      cfg.AWSRegion,
+			Profile:     cfg.AWSProfile,
+			RoleARN:     cfg.AWSRoleARN,
+			SessionName: cfg.AWSSessionName,
+		}
+		config.Net.TLS.Enable = true
+		config.Net.TLS.Config = &tls.Config{}
path: internal/kafka/kafka.go
user: login: myronjhicks
body: Mainly because I saw that cfg.TLS was a [bool](https://github.com/moov-io/achgateway/blob/master/internal/service/model_inbound.go#L75) as opposed to a more complete [config](https://pkg.go.dev/crypto/tls#Config) object. 

This is my first stab at Go so I could be looking at the wrong reference to cfg.TLS but I thought an empty tls.Config was simple enough seeing some GH chatter related to msk usage that stated this empty config fixed some weird issues for other implementers 
created_at: 2025-03-04T21:36:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,8 +79,10 @@ func OpenTopic(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Topic, erro
 		Set("tls", log.Bool(cfg.TLS)).
 		Set("group", log.String(cfg.Group)).
 		Set("sasl.enable", log.Bool(config.Net.SASL.Enable)).
+		Set("sasl.mechanism", log.String(string(config.Net.SASL.Mechanism))).
 		Set("sasl.user", log.String(cfg.Key)).
 		Set("topic", log.String(cfg.Topic)).
+		Set("aws.region", log.String(cfg.AWSRegion)).
path: internal/kafka/kafka.go
user: login: myronjhicks
body: Good callout
created_at: 2025-03-04T21:37:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,7 +94,23 @@ func OpenSubscription(logger log.Logger, cfg *service.KafkaConfig) (*pubsub.Subs
 	config.Net.TLS.Enable = cfg.TLS
 
 	config.Net.SASL.Enable = cfg.Key != ""
-	config.Net.SASL.Mechanism = sarama.SASLMechanism("PLAIN")
+	// Default to PLAIN if no SASL mechanism is specified
+	switch cfg.SASLMechanism {
+	case "AWS_MSK_IAM":
+		config.Net.SASL.Mechanism = sarama.SASLTypeOAuth
+		config.Net.SASL.TokenProvider = &MSKAccessTokenProvider{
+			Region:      cfg.AWSRegion,
+			Profile:     cfg.AWSProfile,
+			RoleARN:     cfg.AWSRoleARN,
+			SessionName: cfg.AWSSessionName,
+		}
+		config.Net.TLS.Enable = true
+		config.Net.TLS.Config = &tls.Config{}
path: internal/kafka/kafka.go
user: login: adamdecaf
body: Oh yea, that should probably be updated, but not right now. An empty config is a decent default as it's Go's defaults which improve over time. 
created_at: 2025-03-05T20:48:42Z
author_association: MEMBER

PR comments:
user: login: adamdecaf
created_at: 2025-03-04T20:46:59Z
body: I'm thinking we push the AWS specific fields into a sub-object so it's clear what is AWS specific. We may have similar support from Google, Confluent, etc in the future. 
author_association: MEMBER
user: login: adamdecaf
created_at: 2025-03-04T21:00:22Z
body: You should be able to rebase after https://github.com/moov-io/achgateway/pull/252 is merged to resolve that CI error. 
author_association: MEMBER
user: login: adamdecaf
created_at: 2025-03-05T21:04:50Z
body: @myronjhicks if you can rebase from origin/master the tests should run now. 
author_association: MEMBER
user: login: adamdecaf
created_at: 2025-03-07T17:58:18Z
body: Thanks for adding this! You've verified this works on your own cluster, right? We don't have an AWS cluster to test with right now. 
author_association: MEMBER
user: login: myronjhicks
created_at: 2025-03-07T19:46:32Z
body: @adamdecaf Yep, it's running smoothly on our end
author_association: CONTRIBUTOR
user: login: adamdecaf
created_at: 2025-03-12T22:10:22Z
body: Released in https://github.com/moov-io/achgateway/releases/tag/v0.32.0
author_association: MEMBER