-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlibrary.go
More file actions
190 lines (167 loc) · 6.09 KB
/
Copy pathlibrary.go
File metadata and controls
190 lines (167 loc) · 6.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
package iamcacheauth
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
smithycreds "github.com/aws/smithy-go/aws-http-auth/credentials"
"github.com/aws/smithy-go/aws-http-auth/sigv4"
v4 "github.com/aws/smithy-go/aws-http-auth/v4"
)
// emptyPayloadHash is the SHA-256 hash of the empty string, precomputed.
var emptyPayloadHash = sha256.Sum256(nil)
// tokenConfig holds all configuration for token generation.
type tokenConfig struct {
userID string
resourceName string // cacheName (ElastiCache) or clusterName (MemoryDB)
region string
serverless bool
serviceName string // "elasticache" or "memorydb"
credProvider aws.CredentialsProvider
}
// Option configures a [TokenGenerator] using the functional options pattern.
// The available options are:
// - [WithServerless] — marks the target as a serverless cache
type Option func(*tokenConfig) error
// WithServerless marks the target cache as serverless, causing the token to
// include the ResourceType=ServerlessCache query parameter.
func WithServerless() Option {
return func(cfg *tokenConfig) error {
cfg.serverless = true
return nil
}
}
// TokenGenerator generates IAM authentication tokens for ElastiCache or MemoryDB.
// It is safe for concurrent use after construction.
//
// Use [NewElastiCache] or [NewMemoryDB] to create instances.
type TokenGenerator struct {
cfg tokenConfig
}
// NewElastiCache creates a [TokenGenerator] for Amazon ElastiCache.
// cacheName is the replication group ID or serverless cache name.
//
// Region is read from awsCfg.Region (set via [config.WithRegion] or resolved
// from the environment/shared config). Credentials are read from
// awsCfg.Credentials (the provider chain configured in [aws.Config]).
// Both are captured at construction time.
//
// Use [WithServerless] to target a serverless cache.
func NewElastiCache(userID, cacheName string, awsCfg aws.Config, opts ...Option) (*TokenGenerator, error) {
if cacheName == "" {
return nil, fmt.Errorf("iamcacheauth: cacheName must not be empty")
}
return newTokenGenerator(tokenConfig{
userID: userID,
resourceName: cacheName,
region: awsCfg.Region,
serviceName: "elasticache",
credProvider: awsCfg.Credentials,
}, opts)
}
// NewMemoryDB creates a [TokenGenerator] for Amazon MemoryDB.
// clusterName is the MemoryDB cluster name.
//
// Region is read from awsCfg.Region (set via [config.WithRegion] or resolved
// from the environment/shared config). Credentials are read from
// awsCfg.Credentials (the provider chain configured in [aws.Config]).
// Both are captured at construction time.
//
// MemoryDB does not support serverless caches; passing [WithServerless]
// returns an error.
func NewMemoryDB(userID, clusterName string, awsCfg aws.Config, opts ...Option) (*TokenGenerator, error) {
if clusterName == "" {
return nil, fmt.Errorf("iamcacheauth: clusterName must not be empty")
}
gen, err := newTokenGenerator(tokenConfig{
userID: userID,
resourceName: clusterName,
region: awsCfg.Region,
serviceName: "memorydb",
credProvider: awsCfg.Credentials,
}, opts)
if err != nil {
return nil, err
}
if gen.cfg.serverless {
return nil, fmt.Errorf("iamcacheauth: serverless is not supported for MemoryDB")
}
return gen, nil
}
// newTokenGenerator is the shared private constructor. It applies options
// and validates common fields.
func newTokenGenerator(cfg tokenConfig, opts []Option) (*TokenGenerator, error) {
for _, opt := range opts {
if err := opt(&cfg); err != nil {
return nil, err
}
}
if cfg.userID == "" {
return nil, fmt.Errorf("iamcacheauth: userID must not be empty")
}
if cfg.region == "" {
return nil, fmt.Errorf("iamcacheauth: region must not be empty")
}
if cfg.credProvider == nil {
return nil, fmt.Errorf("iamcacheauth: aws.Config must have a Credentials provider")
}
return &TokenGenerator{cfg: cfg}, nil
}
// Token generates a fresh IAM authentication token. Each call produces a
// newly signed token using the current wall-clock time.
//
// The ctx parameter controls the timeout and deadline for credential
// retrieval (e.g. from STS, IMDS, or other credential sources). Use
// [context.WithTimeout] to bound credential retrieval time. Signing itself
// is a local CPU-only operation and completes immediately after credentials
// are obtained.
//
// The returned token is valid for 15 minutes but should not be cached;
// generate a fresh token for each connection attempt.
func (g *TokenGenerator) Token(ctx context.Context) (string, error) {
awsCreds, err := g.cfg.credProvider.Retrieve(ctx)
if err != nil {
return "", fmt.Errorf("iamcacheauth: credential retrieval failed: %w", err)
}
// The smithy-go signer uses its own credential type, not the SDK v2 type.
creds := smithycreds.Credentials{
AccessKeyID: awsCreds.AccessKeyID,
SecretAccessKey: awsCreds.SecretAccessKey,
SessionToken: awsCreds.SessionToken,
}
// X-Amz-Expires must be set before signing so it is included in the
// signed query string.
query := url.Values{}
query.Set("Action", "connect")
query.Set("User", g.cfg.userID)
query.Set("X-Amz-Expires", "900")
// ElastiCache rejects serverless tokens without ResourceType, and
// rejects replication-group tokens that include it.
if g.cfg.serverless {
query.Set("ResourceType", "ServerlessCache")
}
reqURL := fmt.Sprintf("http://%s/?%s", g.cfg.resourceName, query.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return "", fmt.Errorf("iamcacheauth: failed to build signing request: %w", err)
}
signer := sigv4.New()
if err := signer.SignRequest(&sigv4.SignRequestInput{
Request: req,
PayloadHash: emptyPayloadHash[:],
Credentials: creds,
Service: g.cfg.serviceName,
Region: g.cfg.region,
Time: time.Now(),
SignatureType: v4.SignatureTypeQueryString,
}); err != nil {
return "", fmt.Errorf("iamcacheauth: signing failed: %w", err)
}
// The token is the presigned URL without the http:// scheme prefix.
token := strings.TrimPrefix(req.URL.String(), "http://")
return token, nil
}