@@ -13,6 +13,9 @@ const assume_role = @import("assume_role.zig");
1313const config_mod = @import ("config.zig" );
1414const 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
1720pub 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
3250pub 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+ }
0 commit comments