Skip to content

Commit 53f8b90

Browse files
authored
Make LoginCredentialProvider token persistence best-effort (#692)
1 parent 2c78153 commit 53f8b90

2 files changed

Lines changed: 87 additions & 3 deletions

File tree

Sources/SotoCore/Credential/Login/LoginCredentialProvider.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,18 @@ public struct LoginCredentialProvider: CredentialProvider {
206206
refreshToken: tokenResponse.refreshToken
207207
)
208208

209-
// Save updated token to disk
210-
try await tokenFileManager.saveToken(updatedToken, to: tokenPath, fileIO: fileIO, threadPool: threadPool)
209+
// Save updated token to disk (best-effort — may fail in sandboxed environments
210+
// such as SwiftPM plugins where writes outside the package directory are blocked)
211+
do {
212+
try await tokenFileManager.saveToken(updatedToken, to: tokenPath, fileIO: fileIO, threadPool: threadPool)
213+
} catch {
214+
logger.warning(
215+
"Could not persist refreshed credentials to disk. Credentials are valid for this session but will need to be refreshed again next time.",
216+
metadata: ["error": .string("\(error)")]
217+
)
218+
}
211219

212-
// Return expiring credential
220+
// Return expiring credential regardless of whether disk save succeeded
213221
return RotatingCredential(
214222
accessKeyId: tokenResponse.accessToken.accessKeyId,
215223
secretAccessKey: tokenResponse.accessToken.secretAccessKey,

Tests/SotoCoreTests/Credential/Login/LoginCredentialProviderTests.swift

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,82 @@ final class LoginCredentialProviderTests {
511511
}
512512
}
513513

514+
@Test("Get credentials succeeds when token file write fails (sandbox)")
515+
func getCredentialsDespiteWriteFailure() async throws {
516+
let tempDirectory = try createTempDirectory()
517+
defer {
518+
// Restore permissions so cleanup can succeed
519+
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempDirectory.path)
520+
removeTempDirectory(tempDirectory)
521+
}
522+
523+
// Setup expired token file
524+
let privateKey = P256.Signing.PrivateKey()
525+
let pemKey = privateKey.pemRepresentation
526+
let escapedPemKey = pemKey.replacing("\n", with: "\\n")
527+
528+
let tokenData = """
529+
{
530+
"accessToken": {
531+
"accessKeyId": "AKIAOLD123",
532+
"secretAccessKey": "oldsecret",
533+
"sessionToken": "oldsession",
534+
"accountId": "123456789012",
535+
"expiresAt": "2020-01-01T00:00:00Z"
536+
},
537+
"refreshToken": "refresh-token-123",
538+
"dpopKey": "\(escapedPemKey)",
539+
"clientId": "client-id-123",
540+
"idToken": "id-token-123",
541+
"tokenType": "urn:aws:params:oauth:token-type:access_token_sigv4"
542+
}
543+
"""
544+
545+
let sessionData = Data("test-session".utf8)
546+
let hash = SHA256.hash(data: sessionData)
547+
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
548+
549+
let tokenPath = tempDirectory.appendingPathComponent("\(hashString).json")
550+
try tokenData.write(to: tokenPath, atomically: true, encoding: .utf8)
551+
552+
// Make directory read-only AFTER writing the token file (simulates SwiftPM sandbox
553+
// where reads are allowed but writes outside the package directory are blocked)
554+
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: tempDirectory.path)
555+
556+
// Mock HTTP client returns fresh credentials
557+
let mockHTTPClient = MockAWSHTTPClient { _ in
558+
let responseJSON = """
559+
{
560+
"accessToken": {
561+
"accessKeyId": "AKIANEW456",
562+
"secretAccessKey": "newsecret456",
563+
"sessionToken": "newsession456"
564+
},
565+
"tokenType": "urn:aws:params:oauth:token-type:access_token_sigv4",
566+
"expiresIn": 3600,
567+
"refreshToken": "new-refresh-token-456"
568+
}
569+
"""
570+
return (.ok, responseJSON.data(using: .utf8)!)
571+
}
572+
573+
let provider = try LoginCredentialProvider.create(
574+
loginSession: "test-session",
575+
loginRegion: .useast1,
576+
cacheDirectoryOverride: tempDirectory.path,
577+
httpClient: mockHTTPClient
578+
)
579+
580+
// Even though saveToken will fail (read-only directory), the refresh succeeded
581+
// and we should still get the fresh credentials back from the server.
582+
let logger = Logger(label: "test")
583+
let credential = try await provider.getCredential(logger: logger)
584+
585+
#expect(credential.accessKeyId == "AKIANEW456")
586+
#expect(credential.secretAccessKey == "newsecret456")
587+
#expect(credential.sessionToken == "newsession456")
588+
}
589+
514590
@Test("Provider is immutable")
515591
func providerIsImmutable() throws {
516592
let tempDirectory = try createTempDirectory()

0 commit comments

Comments
 (0)