Skip to content

Commit 2c78153

Browse files
authored
Follow source_profile in the AWS config, and improve the handling of role_arn + SSO configurations (#690)
* Respect source_profile in AWS config * Add a test to confirm that an incomplete profile cascades * Prevent capture of errors for unreadable credentials files * Include the origin profile when evaluating a credential change that errors * Update visibiliy of ProfileCredentialError * Throw new CredentialProviderError.fileDoesNotExist * Fix up formatting mistake
1 parent 76b3c13 commit 2c78153

5 files changed

Lines changed: 490 additions & 33 deletions

File tree

Sources/SotoCore/Credential/ConfigFileCredentialProvider.swift

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ final class ConfigFileCredentialProvider: CredentialProviderSelector {
7070
profile: profile,
7171
threadPool: threadPool
7272
)
73-
return try self.credentialProvider(from: sharedCredentials, context: context, endpoint: endpoint)
73+
let provider = try self.credentialProvider(from: sharedCredentials, context: context, endpoint: endpoint)
74+
// Tag any error surfaced from this provider with the originating profile, so a failure
75+
// inside a credential chain (source profile, SSO source, STS AssumeRole, etc.) names
76+
// the profile the caller actually asked to resolve.
77+
return ProfileScopedCredentialProvider(inner: provider, profile: profile)
7478
}
7579

7680
/// Generate credential provider based on shared credentials and profile configuration
@@ -104,3 +108,43 @@ final class ConfigFileCredentialProvider: CredentialProviderSelector {
104108
}
105109
}
106110
}
111+
112+
/// Wraps a credential provider so any error it raises is reported in terms of the profile
113+
/// the caller asked to resolve. Without this, an error from a source profile, SSO source, or
114+
/// STS AssumeRole call does not mention the profile that initiated the lookup.
115+
struct ProfileScopedCredentialProvider: CredentialProvider {
116+
let inner: CredentialProvider
117+
let profile: String
118+
119+
var description: String { "\(inner) (profile: \(profile))" }
120+
121+
func getCredential(logger: Logger) async throws -> Credential {
122+
do {
123+
return try await self.inner.getCredential(logger: logger)
124+
} catch {
125+
throw ProfileCredentialError(profile: self.profile, underlying: error)
126+
}
127+
}
128+
129+
func shutdown() async throws {
130+
try await self.inner.shutdown()
131+
}
132+
}
133+
134+
/// Error thrown when credential resolution for a profile fails. Preserves the underlying
135+
/// error so callers can still inspect the original cause.
136+
public struct ProfileCredentialError: Error, CustomStringConvertible {
137+
/// The profile the caller asked to resolve.
138+
public let profile: String
139+
/// The error raised somewhere within the credential resolution for `profile`.
140+
public let underlying: any Error
141+
142+
public init(profile: String, underlying: any Error) {
143+
self.profile = profile
144+
self.underlying = underlying
145+
}
146+
147+
public var description: String {
148+
"Failed to resolve credentials for profile '\(self.profile)': \(self.underlying)"
149+
}
150+
}

Sources/SotoCore/Credential/ConfigFileLoader.swift

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,16 @@ enum ConfigFileLoader {
8888
/// - missingSecretAccessKey: If the secret access key was not found
8989
public struct ConfigFileError: Error, Equatable {
9090
enum Internal: Equatable {
91+
case fileDoesNotExist
9192
case invalidINIFile
9293
case missingProfile
9394
case missingAccessKeyId
9495
case missingSecretAccessKey
9596
}
9697
let value: Internal
9798

99+
/// Credentials or profile ini file does not exist
100+
public static var fileDoesNotExist: Self { .init(value: .fileDoesNotExist) }
98101
/// Credentials or profile ini file failed to load
99102
public static var invalidINIFile: Self { .init(value: .invalidINIFile) }
100103
/// Cannot find profile in ini file
@@ -120,28 +123,39 @@ enum ConfigFileLoader {
120123
threadPool: NIOThreadPool = .singleton
121124
) async throws -> SharedCredentials {
122125
let fileIO = NonBlockingFileIO(threadPool: threadPool)
123-
let credentialsINIParser: INIParser
126+
// Only treat "file does not exist" as a soft miss. Other failures (permission denied,
127+
// I/O errors, malformed INI) propagate so the caller sees why credential lookup failed
128+
// instead of silently falling through to the next provider.
129+
let credentialsINIParser: INIParser?
124130
do {
125-
// Load credentials file
126-
credentialsINIParser = try await self.loadINIFile(
127-
path: credentialsFilePath,
128-
fileIO: fileIO
129-
)
130-
} catch {
131-
// Throw `.noProvider` error if credential file cannot be loaded
132-
throw CredentialProviderError.noProvider
131+
credentialsINIParser = try await self.loadINIFile(path: credentialsFilePath, fileIO: fileIO)
132+
} catch let error as ConfigFileError where error == ConfigFileError.fileDoesNotExist {
133+
credentialsINIParser = nil
133134
}
134135
let configINIParser: INIParser?
135136
do {
136-
// Load profile config file
137-
configINIParser = try await self.loadINIFile(
138-
path: configFilePath,
139-
fileIO: fileIO
140-
)
141-
} catch {
137+
configINIParser = try await self.loadINIFile(path: configFilePath, fileIO: fileIO)
138+
} catch let error as ConfigFileError where error == ConfigFileError.fileDoesNotExist {
142139
configINIParser = nil
143140
}
144-
return try self.parseSharedCredentials(from: credentialsINIParser, configINIParser: configINIParser, for: profile)
141+
142+
// If neither file is available there's nothing to resolve — fall through to the next
143+
// provider in the chain, matching the historical contract for a missing credentials file.
144+
if credentialsINIParser == nil && configINIParser == nil {
145+
throw CredentialProviderError.noProvider
146+
}
147+
148+
do {
149+
return try self.parseSharedCredentials(
150+
from: credentialsINIParser,
151+
configINIParser: configINIParser,
152+
for: profile
153+
)
154+
} catch let error as ConfigFileError where error == .missingProfile && credentialsINIParser == nil {
155+
// The credentials file was unavailable and the config file didn't supply the profile
156+
// either — preserve the "fall through to the next provider" behavior.
157+
throw CredentialProviderError.noProvider
158+
}
145159
}
146160

147161
/// Load a file from disk without blocking the current thread
@@ -162,7 +176,13 @@ enum ConfigFileLoader {
162176
/// - fileIO: non-blocking file IO
163177
/// - Returns: INIParser
164178
static func loadINIFile(path: String, fileIO: NonBlockingFileIO) async throws -> INIParser {
165-
let buffer = try await loadFile(path: path, fileIO: fileIO)
179+
let buffer: ByteBuffer
180+
do {
181+
buffer = try await loadFile(path: path, fileIO: fileIO)
182+
} catch let error as IOError where error.errnoCode == ENOENT {
183+
throw ConfigFileError.fileDoesNotExist
184+
}
185+
166186
let content = String(buffer: buffer)
167187
guard let parser = try? INIParser(content) else {
168188
throw ConfigFileError.invalidINIFile
@@ -184,34 +204,58 @@ enum ConfigFileLoader {
184204
/// - profile: named profile to load (optional)
185205
/// - Returns: Parsed SharedCredentials
186206
static func parseSharedCredentials(
187-
from credentialsINIParser: INIParser,
207+
from credentialsINIParser: INIParser?,
188208
configINIParser: INIParser?,
189209
for profile: String
190210
) throws -> SharedCredentials {
191211
let config = try configINIParser.flatMap { try self.parseProfileConfig(from: $0, for: profile) }
192-
let credentials = try parseCredentials(from: credentialsINIParser, for: profile, sourceProfile: config?.sourceProfile)
212+
213+
// The profile may live only in the config file (typical for assume-role chains whose
214+
// source is an SSO profile). Tolerate `.missingProfile` from `parseCredentials` when
215+
// the config file already supplies a `role_arn`, and skip the call entirely when
216+
// the credentials file is unavailable.
217+
let credentials: ProfileCredentials?
218+
if let credentialsINIParser {
219+
do {
220+
credentials = try parseCredentials(from: credentialsINIParser, for: profile, sourceProfile: config?.sourceProfile)
221+
} catch let error as ConfigFileError where error == .missingProfile && config?.roleArn != nil {
222+
credentials = nil
223+
}
224+
} else {
225+
credentials = nil
226+
}
193227

194228
// If `role_arn` is defined, check for source profile or credential source
195-
if let roleArn = credentials.roleArn ?? config?.roleArn {
196-
let sessionName = credentials.roleSessionName ?? config?.roleSessionName ?? UUID().uuidString
229+
if let roleArn = credentials?.roleArn ?? config?.roleArn {
230+
let sessionName = credentials?.roleSessionName ?? config?.roleSessionName ?? UUID().uuidString
197231
let region = config?.region ?? .useast1
198232
// If `source_profile` is defined, temporary credentials must be loaded via STS AssumeRole operation
199-
if let _ = credentials.sourceProfile ?? config?.sourceProfile {
200-
guard let accessKey = credentials.accessKey else {
233+
if let sourceProfileName = credentials?.sourceProfile ?? config?.sourceProfile {
234+
// If the source profile is an SSO profile (per the config file), use SSO to obtain
235+
// the source credentials instead of looking up static keys.
236+
if let configINIParser, Self.isSSOProfile(in: configINIParser, profile: sourceProfileName) {
237+
return .assumeRole(
238+
roleArn: roleArn,
239+
sessionName: sessionName,
240+
region: region,
241+
sourceCredentialProvider: .sso(profileName: sourceProfileName)
242+
)
243+
}
244+
guard let accessKey = credentials?.accessKey else {
201245
throw ConfigFileError.missingAccessKeyId
202246
}
203-
guard let secretAccessKey = credentials.secretAccessKey else {
247+
guard let secretAccessKey = credentials?.secretAccessKey else {
204248
throw ConfigFileError.missingSecretAccessKey
205249
}
206250
let provider: CredentialProviderFactory = .static(
207251
accessKeyId: accessKey,
208252
secretAccessKey: secretAccessKey,
209-
sessionToken: credentials.sessionToken
253+
sessionToken: credentials?.sessionToken
210254
)
211255
return .assumeRole(roleArn: roleArn, sessionName: sessionName, region: region, sourceCredentialProvider: provider)
212256
}
213257
// If `credental_source` is defined, temporary credentials must be loaded from source
214-
else if let credentialSource = credentials.credentialSource ?? config?.credentialSource {
258+
else if let credentialSource = credentials?.credentialSource ?? config?.credentialSource {
215259
let provider: CredentialProviderFactory
216260
switch credentialSource {
217261
case .environment:
@@ -228,6 +272,9 @@ enum ConfigFileLoader {
228272
}
229273

230274
// Return static credentials
275+
guard let credentials else {
276+
throw ConfigFileError.missingProfile
277+
}
231278
guard let accessKey = credentials.accessKey else {
232279
throw ConfigFileError.missingAccessKeyId
233280
}
@@ -238,6 +285,14 @@ enum ConfigFileLoader {
238285
return .staticCredential(credential: credential)
239286
}
240287

288+
/// Returns true if the given profile's section in the AWS config file declares SSO
289+
/// (either the modern `sso_session` reference or the legacy `sso_start_url` keys).
290+
static func isSSOProfile(in configINIParser: INIParser, profile: String) -> Bool {
291+
let sectionKey = profile == Self.defaultProfile ? profile : "profile \(profile)"
292+
guard let section = configINIParser.sections[sectionKey] else { return false }
293+
return section["sso_session"] != nil || section["sso_start_url"] != nil
294+
}
295+
241296
/// Parse profile configuraton from a file (passed in as byte-buffer), usually `~/.aws/config`
242297
///
243298
/// - Parameters:

Tests/SotoCoreTests/Credential/ConfigFileCredentialProviderTests.swift

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
import AsyncHTTPClient
16+
import Logging
1617
import NIOCore
1718
import NIOPosix
1819
import SotoTestUtils
@@ -79,6 +80,57 @@ class ConfigFileCredentialProviderTests: XCTestCase {
7980
try await httpClient.shutdown()
8081
}
8182

83+
func testProfileScopedProviderWrapsErrorWithProfileName() async throws {
84+
// Direct unit test for the wrapper: any error from the inner provider must surface as
85+
// a `ProfileCredentialError` whose description includes the profile name and whose
86+
// `underlying` preserves the original error.
87+
struct StubError: Error, Equatable {}
88+
struct ThrowingProvider: CredentialProvider {
89+
func getCredential(logger: Logger) async throws -> Credential { throw StubError() }
90+
}
91+
let wrapped = ProfileScopedCredentialProvider(inner: ThrowingProvider(), profile: "dev")
92+
93+
do {
94+
_ = try await wrapped.getCredential(logger: TestEnvironment.logger)
95+
XCTFail("Expected ProfileCredentialError")
96+
} catch let error as ProfileCredentialError {
97+
XCTAssertEqual(error.profile, "dev")
98+
XCTAssertTrue(error.underlying is StubError)
99+
XCTAssertTrue(error.description.contains("dev"), "Description should name the profile: \(error.description)")
100+
} catch {
101+
XCTFail("Expected ProfileCredentialError, got \(error)")
102+
}
103+
}
104+
105+
func testCredentialProviderTagsErrorsWithProfileFromFile() async throws {
106+
// Integration check: loading via the file overload must produce a `ProfileScopedCredentialProvider`
107+
// bound to the requested profile, so any failure from inside the chain surfaces with that profile.
108+
let profile = "dev"
109+
let credentialsFile = """
110+
[\(profile)]
111+
aws_access_key_id = AKIAIOSFODNN7EXAMPLE
112+
aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
113+
"""
114+
let credentialsPath = "creds-\(UUID().uuidString)"
115+
try credentialsFile.write(toFile: credentialsPath, atomically: true, encoding: .utf8)
116+
defer { try? FileManager.default.removeItem(atPath: credentialsPath) }
117+
let (context, httpClient) = self.makeContext()
118+
119+
let provider = try await ConfigFileCredentialProvider.credentialProvider(
120+
from: credentialsPath,
121+
configFilePath: "/dev/null",
122+
for: profile,
123+
context: context,
124+
endpoint: nil
125+
)
126+
127+
let scoped = try XCTUnwrap(provider as? ProfileScopedCredentialProvider)
128+
XCTAssertEqual(scoped.profile, profile)
129+
130+
try await provider.shutdown()
131+
try await httpClient.shutdown()
132+
}
133+
82134
// MARK: - Config File Credentials Provider
83135

84136
func testConfigFileSuccess() async throws {
@@ -135,15 +187,22 @@ class ConfigFileCredentialProviderTests: XCTestCase {
135187
}
136188

137189
func testConfigFileNotAvailable() async {
138-
let filename = #function
139-
let filenameURL = URL(fileURLWithPath: filename)
190+
let credentialsFilename = "credentials_\(#function)"
191+
let credentialsFilenameURL = URL(fileURLWithPath: credentialsFilename)
192+
193+
let configFilename = "config_\(#function)"
194+
let configFilenameURL = URL(fileURLWithPath: configFilename)
140195

141196
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
142197
let eventLoop = eventLoopGroup.next()
143198
defer { XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) }
144199
let httpClient = HTTPClient(eventLoopGroupProvider: .shared(eventLoop))
145200
defer { XCTAssertNoThrow(try httpClient.syncShutdown()) }
146-
let factory = CredentialProviderFactory.configFile(credentialsFilePath: filenameURL.path)
201+
202+
let factory = CredentialProviderFactory.configFile(
203+
credentialsFilePath: credentialsFilenameURL.path,
204+
configFilePath: configFilenameURL.path,
205+
)
147206

148207
let provider = factory.createProvider(context: .init(httpClient: httpClient, logger: TestEnvironment.logger, options: .init()))
149208

0 commit comments

Comments
 (0)