Skip to content

Commit e16456b

Browse files
committed
Add credential caching improvements
Bring ChainProvider closer to the AWS Rust SDK's LazyCache by adding jittered expiry buffer (avoids thundering herd), a mutex for thread-safe caching, and configurable default expiration for permanent credentials.
1 parent a56c58a commit e16456b

4 files changed

Lines changed: 222 additions & 11 deletions

File tree

src/config.zig

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ pub const LoadOptions = struct {
102102
timeout_ms: ?u32 = null,
103103
/// STS regional endpoint mode
104104
sts_regional_endpoints: ?StsRegionalEndpoints = null,
105+
/// Seconds before expiration to consider credentials stale
106+
expiry_buffer: ?i64 = null,
107+
/// Default expiration (seconds) for permanent credentials (null = cache forever)
108+
default_expiration: ?i64 = null,
105109
};
106110

107111
/// AWS SDK configuration shared across service clients
@@ -150,11 +154,18 @@ pub const Config = struct {
150154
const region = try resolveRegion(allocator, options, profile);
151155
errdefer allocator.free(region);
152156

157+
var chain = ChainProvider{
158+
.profile = resolved_profile,
159+
.region = region,
160+
};
161+
if (options.expiry_buffer) |eb| {
162+
chain.expiry_buffer = eb;
163+
}
164+
if (options.default_expiration) |de| {
165+
chain.default_expiration = de;
166+
}
153167
const credentials = options.credentials orelse CredentialsProvider{
154-
.chain = ChainProvider{
155-
.profile = resolved_profile,
156-
.region = region,
157-
},
168+
.chain = chain,
158169
};
159170

160171
const endpoint_url: ?[]const u8 = options.endpoint_url orelse

src/credentials.zig

Lines changed: 140 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const assume_role = @import("assume_role.zig");
1313
const config_mod = @import("config.zig");
1414
const sts_common = @import("sts_common.zig");
1515

16+
/// Default buffer (seconds) before expiration to consider credentials stale.
17+
pub const default_expiry_buffer: i64 = 300;
18+
1619
/// AWS credentials for request signing
1720
pub const Credentials = struct {
1821
access_key_id: []const u8,
@@ -21,13 +24,28 @@ pub const Credentials = struct {
2124
/// Expiration time (epoch seconds), null if permanent
2225
expiration: ?i64 = null,
2326

24-
/// Check if credentials are expired (with 5 minute buffer)
27+
/// Check if credentials are expired (with default 5 minute buffer)
2528
pub fn isExpired(self: Credentials) bool {
29+
return self.isExpiredWithBuffer(default_expiry_buffer);
30+
}
31+
32+
/// Check if credentials are expired with a custom buffer (seconds)
33+
pub fn isExpiredWithBuffer(self: Credentials, buffer: i64) bool {
2634
const exp = self.expiration orelse return false;
27-
return std.time.timestamp() >= (exp - 300);
35+
return std.time.timestamp() >= (exp - buffer);
2836
}
2937
};
3038

39+
/// Return a jittered buffer value in [buffer/2, buffer] (seconds).
40+
/// Uses cryptographic randomness to avoid thundering herd on refresh.
41+
pub fn jitteredBuffer(buffer: i64) i64 {
42+
if (buffer <= 1) return buffer;
43+
const half: u64 = @intCast(@divTrunc(buffer, 2));
44+
const range: u64 = @intCast(buffer - @as(i64, @intCast(half)) + 1);
45+
const jitter = std.crypto.random.intRangeLessThan(u64, 0, range);
46+
return @intCast(half + jitter);
47+
}
48+
3149
/// Credential provider - tagged union of supported providers
3250
pub const CredentialsProvider = union(enum) {
3351
/// Static credentials provided directly
@@ -246,6 +264,13 @@ pub const ChainProvider = struct {
246264
ecs_provider: ?EcsProvider = null,
247265
/// Web identity provider (reused across calls)
248266
web_identity_provider: ?web_identity.WebIdentityProvider = null,
267+
/// Seconds before expiration to consider credentials stale
268+
expiry_buffer: i64 = default_expiry_buffer,
269+
/// Default expiration (seconds) for permanent credentials.
270+
/// null = cache forever (original behavior).
271+
default_expiration: ?i64 = 900,
272+
/// Mutex for thread-safe credential caching
273+
mutex: std.Thread.Mutex = .{},
249274

250275
const Self = @This();
251276

@@ -263,18 +288,21 @@ pub const ChainProvider = struct {
263288

264289
/// Get credentials, using cache if valid
265290
pub fn getCredentials(self: *Self, allocator: Allocator) !Credentials {
291+
self.mutex.lock();
292+
defer self.mutex.unlock();
293+
266294
// Return cached credentials if still valid
267295
if (self.cached) |creds| {
268-
if (!creds.isExpired()) {
296+
if (!creds.isExpiredWithBuffer(jitteredBuffer(self.expiry_buffer))) {
269297
return creds;
270298
}
271299
}
272300

273301
// If we previously succeeded with a provider, try it first
274302
if (self.successful_provider) |provider| {
275303
if (self.tryProvider(allocator, provider)) |creds| {
276-
self.cached = creds;
277-
return creds;
304+
self.cacheCredentials(creds);
305+
return self.cached.?;
278306
} else |_| {
279307
// Provider failed, clear it and try the full chain
280308
self.successful_provider = null;
@@ -295,9 +323,9 @@ pub const ChainProvider = struct {
295323
};
296324
for (providers) |provider| {
297325
if (self.tryProvider(allocator, provider)) |creds| {
298-
self.cached = creds;
326+
self.cacheCredentials(creds);
299327
self.successful_provider = provider;
300-
return creds;
328+
return self.cached.?;
301329
} else |_| {
302330
// Continue to next provider
303331
}
@@ -471,8 +499,21 @@ pub const ChainProvider = struct {
471499
};
472500
}
473501

502+
/// Cache credentials, stamping permanent ones with a synthetic expiration.
503+
fn cacheCredentials(self: *Self, creds: Credentials) void {
504+
var cached = creds;
505+
if (cached.expiration == null) {
506+
if (self.default_expiration) |ttl| {
507+
cached.expiration = std.time.timestamp() + ttl;
508+
}
509+
}
510+
self.cached = cached;
511+
}
512+
474513
/// Clear cached credentials (forces refresh on next call)
475514
pub fn clearCache(self: *Self) void {
515+
self.mutex.lock();
516+
defer self.mutex.unlock();
476517
self.cached = null;
477518
}
478519

@@ -777,3 +818,95 @@ test "profile_sso skips when profile lacks sso_session" {
777818
result,
778819
);
779820
}
821+
822+
test "jitteredBuffer returns values in [buffer/2, buffer]" {
823+
const buffer: i64 = 300;
824+
const half = @divTrunc(buffer, 2);
825+
for (0..100) |_| {
826+
const result = jitteredBuffer(buffer);
827+
try std.testing.expect(result >= half);
828+
try std.testing.expect(result <= buffer);
829+
}
830+
}
831+
832+
test "jitteredBuffer edge cases" {
833+
try std.testing.expectEqual(@as(i64, 0), jitteredBuffer(0));
834+
try std.testing.expectEqual(@as(i64, 1), jitteredBuffer(1));
835+
}
836+
837+
test "isExpiredWithBuffer with custom buffer" {
838+
const now = std.time.timestamp();
839+
const creds = Credentials{
840+
.access_key_id = "test",
841+
.secret_access_key = "test",
842+
.expiration = now + 100,
843+
};
844+
// With a 200-second buffer, should be expired
845+
try std.testing.expect(creds.isExpiredWithBuffer(200));
846+
// With a 50-second buffer, should not be expired
847+
try std.testing.expect(!creds.isExpiredWithBuffer(50));
848+
}
849+
850+
test "cacheCredentials stamps permanent credentials with synthetic expiration" {
851+
var chain = ChainProvider{
852+
.default_expiration = 900,
853+
};
854+
const creds = Credentials{
855+
.access_key_id = "PERM_KEY",
856+
.secret_access_key = "PERM_SECRET",
857+
};
858+
chain.cacheCredentials(creds);
859+
// Cached copy should have an expiration set
860+
try std.testing.expect(chain.cached.?.expiration != null);
861+
const exp = chain.cached.?.expiration.?;
862+
const now = std.time.timestamp();
863+
// Should be roughly now + 900 (allow 5 second tolerance)
864+
try std.testing.expect(exp >= now + 895);
865+
try std.testing.expect(exp <= now + 905);
866+
}
867+
868+
test "cacheCredentials leaves already-expiring credentials unchanged" {
869+
var chain = ChainProvider{
870+
.default_expiration = 900,
871+
};
872+
const now = std.time.timestamp();
873+
const original_exp = now + 3600;
874+
const creds = Credentials{
875+
.access_key_id = "TEMP_KEY",
876+
.secret_access_key = "TEMP_SECRET",
877+
.expiration = original_exp,
878+
};
879+
chain.cacheCredentials(creds);
880+
try std.testing.expectEqual(original_exp, chain.cached.?.expiration.?);
881+
}
882+
883+
test "multi-threaded getCredentials does not crash" {
884+
var chain = ChainProvider{
885+
.default_expiration = null,
886+
};
887+
// Pre-populate cache so threads don't hit real providers
888+
chain.cached = Credentials{
889+
.access_key_id = "MT_KEY",
890+
.secret_access_key = "MT_SECRET",
891+
.expiration = std.time.timestamp() + 3600,
892+
};
893+
894+
const Thread = std.Thread;
895+
const num_threads = 4;
896+
var threads: [num_threads]Thread = undefined;
897+
for (&threads) |*t| {
898+
t.* = try Thread.spawn(.{}, struct {
899+
fn run(c: *ChainProvider) void {
900+
for (0..50) |_| {
901+
const creds = c.getCredentials(std.testing.allocator) catch return;
902+
std.testing.expectEqualStrings("MT_KEY", creds.access_key_id) catch return;
903+
}
904+
}
905+
}.run, .{&chain});
906+
}
907+
for (&threads) |*t| {
908+
t.join();
909+
}
910+
// If we got here without crashing, the mutex is working
911+
try std.testing.expect(chain.cached != null);
912+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sts
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const std = @import("std");
2+
const aws = @import("aws");
3+
const sts = @import("sts");
4+
5+
test "chain caches and reuses credentials" {
6+
const allocator = std.testing.allocator;
7+
8+
var cfg = try aws.Config.load(allocator, .{});
9+
defer cfg.deinit();
10+
11+
// First call — populates cache
12+
const creds1 = try cfg.credentials.getCredentials(allocator);
13+
// Second call — should return cached copy
14+
const creds2 = try cfg.credentials.getCredentials(allocator);
15+
16+
// Same access key both times (cached)
17+
try std.testing.expectEqualStrings(creds1.access_key_id, creds2.access_key_id);
18+
19+
// Confirm cached credentials actually work with a real call
20+
var client = sts.Client.initWithOptions(allocator, &cfg, .{ .keep_alive = false });
21+
defer client.deinit();
22+
23+
var arena = std.heap.ArenaAllocator.init(allocator);
24+
defer arena.deinit();
25+
26+
const result = try sts.get_caller_identity.execute(
27+
&client,
28+
arena.allocator(),
29+
.{},
30+
.{},
31+
);
32+
try std.testing.expect(result.account != null);
33+
}
34+
35+
test "config passes expiry_buffer through LoadOptions" {
36+
const allocator = std.testing.allocator;
37+
38+
var cfg = try aws.Config.load(allocator, .{ .expiry_buffer = 0 });
39+
defer cfg.deinit();
40+
41+
switch (cfg.credentials) {
42+
.chain => |chain| {
43+
try std.testing.expectEqual(@as(i64, 0), chain.expiry_buffer);
44+
},
45+
else => return error.CredentialsNotFound,
46+
}
47+
}
48+
49+
test "default expiration applied to permanent credentials" {
50+
const allocator = std.testing.allocator;
51+
52+
var cfg = try aws.Config.load(allocator, .{ .default_expiration = 900 });
53+
defer cfg.deinit();
54+
55+
// Fetch credentials (env-based credentials have no natural expiration)
56+
_ = try cfg.credentials.getCredentials(allocator);
57+
58+
// The chain's cached copy should have a synthetic expiration
59+
switch (cfg.credentials) {
60+
.chain => |chain| {
61+
try std.testing.expect(chain.cached != null);
62+
try std.testing.expect(chain.cached.?.expiration != null);
63+
},
64+
else => return error.CredentialsNotFound,
65+
}
66+
}

0 commit comments

Comments
 (0)