Skip to content

Commit 9c31dc2

Browse files
sebstoclaude
andauthored
Add credential_process support (#695)
* Add credential_process support for AWS config profiles Implement the `credential_process` configuration key from AWS shared credentials/config files. When a profile specifies this key, the SDK executes the command via /bin/sh -c, parses JSON credentials from stdout, and returns either static or expiring credentials depending on the presence of an Expiration field. Includes a test helper binary for end-to-end testing and comprehensive Swift Testing coverage for config parsing, process execution, and error handling. Closes #641 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * swift format * Add comment explaining why test helper avoids Foundation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix Android build: import Android for exit/gmtime_r Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add unit tests for ISO8601 date parsing and formatting utility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Guard credential_process with #if os(macOS) || os(Linux) Foundation.Process is not available on iOS/tvOS/watchOS. The credential_process feature only makes sense on platforms with a shell. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * swift format * Remove .kiro directory * Use a shell script as test credentails providers * fix test on Linux --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 53f8b90 commit 9c31dc2

11 files changed

Lines changed: 889 additions & 58 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ Package.resolved
99
/build
1010
/docs
1111

12+
.kiro

Package.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ let package = Package(
123123
.byName(name: "SotoTestUtils"),
124124
.product(name: "NIOPosix", package: "swift-nio"),
125125
.product(name: "InMemoryTracing", package: "swift-distributed-tracing"),
126+
],
127+
resources: [
128+
.copy("Resources/credential-process-test-helper.sh")
126129
]
127130
),
128131
.testTarget(

Sources/SotoCore/Credential/ConfigFileCredentialProvider.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ final class ConfigFileCredentialProvider: CredentialProviderSelector {
105105
httpClient: context.httpClient,
106106
endpoint: endpoint
107107
)
108+
#if os(macOS) || os(Linux)
109+
case .credentialProcess(let command):
110+
return CredentialProcessProvider(command: command)
111+
#endif
108112
}
109113
}
110114
}

Sources/SotoCore/Credential/ConfigFileLoader.swift

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ enum ConfigFileLoader {
4646
enum SharedCredentials {
4747
case staticCredential(credential: StaticCredential)
4848
case assumeRole(roleArn: String, sessionName: String, region: Region?, sourceCredentialProvider: CredentialProviderFactory)
49+
#if os(macOS) || os(Linux)
50+
case credentialProcess(command: String)
51+
#endif
4952
}
5053

5154
/// Credentials file – The credentials and config file are updated when you run the command aws configure. The credentials file is located
@@ -59,6 +62,7 @@ enum ConfigFileLoader {
5962
let roleSessionName: String?
6063
let sourceProfile: String?
6164
let credentialSource: CredentialSource?
65+
let credentialProcess: String?
6266
}
6367

6468
/// The credentials and config file are updated when you run the command aws configure. The config file is located at `~/.aws/config` on Linux
@@ -69,6 +73,7 @@ enum ConfigFileLoader {
6973
let roleSessionName: String?
7074
let sourceProfile: String?
7175
let credentialSource: CredentialSource?
76+
let credentialProcess: String?
7277
}
7378

7479
/// Profile credential source `credential_source`
@@ -211,14 +216,14 @@ enum ConfigFileLoader {
211216
let config = try configINIParser.flatMap { try self.parseProfileConfig(from: $0, for: profile) }
212217

213218
// 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.
219+
// source is an SSO profile, or credential_process profiles). Tolerate `.missingProfile`
220+
// from `parseCredentials` when the config file already supplies a `role_arn` or
221+
// `credential_process`, and skip the call entirely when the credentials file is unavailable.
217222
let credentials: ProfileCredentials?
218223
if let credentialsINIParser {
219224
do {
220225
credentials = try parseCredentials(from: credentialsINIParser, for: profile, sourceProfile: config?.sourceProfile)
221-
} catch let error as ConfigFileError where error == .missingProfile && config?.roleArn != nil {
226+
} catch let error as ConfigFileError where error == .missingProfile && (config?.roleArn != nil || config?.credentialProcess != nil) {
222227
credentials = nil
223228
}
224229
} else {
@@ -266,11 +271,26 @@ enum ConfigFileLoader {
266271
provider = .ecs
267272
}
268273
return .assumeRole(roleArn: roleArn, sessionName: sessionName, region: region, sourceCredentialProvider: provider)
274+
} else {
275+
#if os(macOS) || os(Linux)
276+
// If `credential_process` is defined, use it as source credentials for assume-role
277+
if let credentialProcess = credentials?.credentialProcess ?? config?.credentialProcess {
278+
let provider: CredentialProviderFactory = .credentialProcess(command: credentialProcess)
279+
return .assumeRole(roleArn: roleArn, sessionName: sessionName, region: region, sourceCredentialProvider: provider)
280+
}
281+
#endif
282+
// Invalid configuration
283+
throw ConfigFileError.invalidINIFile
269284
}
270-
// Invalid configuration
271-
throw ConfigFileError.invalidINIFile
272285
}
273286

287+
#if os(macOS) || os(Linux)
288+
// If `credential_process` is defined (credentials file takes precedence over config)
289+
if let credentialProcess = credentials?.credentialProcess ?? config?.credentialProcess {
290+
return .credentialProcess(command: credentialProcess)
291+
}
292+
#endif
293+
274294
// Return static credentials
275295
guard let credentials else {
276296
throw ConfigFileError.missingProfile
@@ -317,7 +337,8 @@ enum ConfigFileLoader {
317337
roleArn: settings["role_arn"],
318338
roleSessionName: settings["role_session_name"],
319339
sourceProfile: settings["source_profile"],
320-
credentialSource: settings["credential_source"].flatMap(CredentialSource.init(rawValue:))
340+
credentialSource: settings["credential_source"].flatMap(CredentialSource.init(rawValue:)),
341+
credentialProcess: settings["credential_process"]
321342
)
322343
}
323344

@@ -355,7 +376,8 @@ enum ConfigFileLoader {
355376
roleArn: settings["role_arn"],
356377
roleSessionName: settings["role_session_name"],
357378
sourceProfile: sourceProfile ?? settings["source_profile"],
358-
credentialSource: settings["credential_source"].flatMap(CredentialSource.init(rawValue:))
379+
credentialSource: settings["credential_source"].flatMap(CredentialSource.init(rawValue:)),
380+
credentialProcess: settings["credential_process"]
359381
)
360382
}
361383

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Soto for AWS open source project
4+
//
5+
// Copyright (c) 2017-2026 the Soto project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Soto project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
#if os(macOS) || os(Linux)
16+
17+
import Foundation
18+
import Logging
19+
import SotoSignerV4
20+
21+
/// Obtains AWS credentials by executing an external process specified via `credential_process`.
22+
///
23+
/// The command is executed via `/bin/sh -c` and must output JSON to stdout matching the
24+
/// AWS credential_process specification:
25+
/// https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-sourcing-external.html
26+
public struct CredentialProcessProvider: CredentialProvider {
27+
let command: String
28+
29+
public init(command: String) {
30+
self.command = command
31+
}
32+
33+
public var description: String { "CredentialProcessProvider" }
34+
35+
public func getCredential(logger: Logger) async throws -> Credential {
36+
guard !command.isEmpty else {
37+
throw CredentialProcessError.commandEmpty
38+
}
39+
40+
logger.debug("Executing credential_process", metadata: ["command": .string(command)])
41+
42+
let output = try await self.executeProcess()
43+
44+
guard !output.isEmpty else {
45+
throw CredentialProcessError.failedToDecodeOutput
46+
}
47+
48+
let decoded: CredentialProcessOutput
49+
do {
50+
decoded = try JSONDecoder().decode(CredentialProcessOutput.self, from: output)
51+
} catch {
52+
throw CredentialProcessError.failedToDecodeOutput
53+
}
54+
55+
guard decoded.version == 1 else {
56+
throw CredentialProcessError.invalidVersion(decoded.version)
57+
}
58+
59+
if let expirationString = decoded.expiration {
60+
guard let date = parseISO8601Date(expirationString) else {
61+
throw CredentialProcessError.invalidExpiration
62+
}
63+
return RotatingCredential(
64+
accessKeyId: decoded.accessKeyId,
65+
secretAccessKey: decoded.secretAccessKey,
66+
sessionToken: decoded.sessionToken,
67+
expiration: date
68+
)
69+
} else {
70+
return StaticCredential(
71+
accessKeyId: decoded.accessKeyId,
72+
secretAccessKey: decoded.secretAccessKey,
73+
sessionToken: decoded.sessionToken
74+
)
75+
}
76+
}
77+
78+
private func executeProcess() async throws -> Data {
79+
try await withCheckedThrowingContinuation { continuation in
80+
let process = Process()
81+
process.executableURL = URL(fileURLWithPath: "/bin/sh")
82+
process.arguments = ["-c", command]
83+
84+
let pipe = Pipe()
85+
process.standardOutput = pipe
86+
process.standardError = nil
87+
88+
do {
89+
try process.run()
90+
} catch {
91+
continuation.resume(throwing: CredentialProcessError.failedToDecodeOutput)
92+
return
93+
}
94+
95+
process.waitUntilExit()
96+
97+
let status = process.terminationStatus
98+
guard status == 0 else {
99+
continuation.resume(throwing: CredentialProcessError.processExitedWithError(status))
100+
return
101+
}
102+
103+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
104+
continuation.resume(returning: data)
105+
}
106+
}
107+
108+
}
109+
110+
// MARK: - Supporting Types
111+
112+
private struct CredentialProcessOutput: Decodable, Sendable {
113+
let version: Int
114+
let accessKeyId: String
115+
let secretAccessKey: String
116+
let sessionToken: String?
117+
let expiration: String?
118+
119+
enum CodingKeys: String, CodingKey {
120+
case version = "Version"
121+
case accessKeyId = "AccessKeyId"
122+
case secretAccessKey = "SecretAccessKey"
123+
case sessionToken = "SessionToken"
124+
case expiration = "Expiration"
125+
}
126+
}
127+
128+
public struct CredentialProcessError: Error, Equatable, CustomStringConvertible {
129+
enum Internal: Equatable {
130+
case commandEmpty
131+
case processExitedWithError(Int32)
132+
case invalidVersion(Int)
133+
case failedToDecodeOutput
134+
case invalidExpiration
135+
}
136+
137+
let value: Internal
138+
139+
public static var commandEmpty: Self { .init(value: .commandEmpty) }
140+
public static func processExitedWithError(_ code: Int32) -> Self { .init(value: .processExitedWithError(code)) }
141+
public static func invalidVersion(_ version: Int) -> Self { .init(value: .invalidVersion(version)) }
142+
public static var failedToDecodeOutput: Self { .init(value: .failedToDecodeOutput) }
143+
public static var invalidExpiration: Self { .init(value: .invalidExpiration) }
144+
145+
public var description: String {
146+
switch value {
147+
case .commandEmpty:
148+
"credential_process command is empty"
149+
case .processExitedWithError(let code):
150+
"credential_process exited with code \(code)"
151+
case .invalidVersion(let v):
152+
"credential_process returned unsupported Version \(v) (expected 1)"
153+
case .failedToDecodeOutput:
154+
"credential_process output could not be decoded as JSON"
155+
case .invalidExpiration:
156+
"credential_process returned an invalid Expiration timestamp"
157+
}
158+
}
159+
}
160+
161+
#endif

Sources/SotoCore/Credential/CredentialProvider.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,17 @@ extension CredentialProviderFactory {
191191
}
192192
}
193193
}
194+
195+
#if os(macOS) || os(Linux)
196+
/// Use credentials obtained by executing an external process.
197+
///
198+
/// The command is executed via `/bin/sh -c` and must output JSON to stdout matching the
199+
/// AWS `credential_process` specification.
200+
public static func credentialProcess(command: String) -> CredentialProviderFactory {
201+
Self { context in
202+
let provider = CredentialProcessProvider(command: command)
203+
return RotatingCredentialProvider(context: context, provider: provider)
204+
}
205+
}
206+
#endif
194207
}

Sources/SotoCore/Credential/SSO/SSOTokenManager.swift

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -246,54 +246,4 @@ struct SSOTokenManager {
246246
)
247247
}
248248

249-
// MARK: - Date Parsing
250-
251-
/// Parse an ISO8601 date string, handling both with and without fractional seconds.
252-
/// AWS CLI writes dates with fractional seconds (e.g., "2026-02-18T16:59:23.216Z").
253-
/// but not all token files have them.
254-
func parseISO8601Date(_ string: String) -> Date? {
255-
#if canImport(FoundationEssentials)
256-
if let date = try? Date(string, strategy: .iso8601) {
257-
return date
258-
}
259-
if let date = try? Date(string, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) {
260-
return date
261-
}
262-
#else
263-
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
264-
if let date = try? Date(string, strategy: .iso8601) {
265-
return date
266-
}
267-
if let date = try? Date(string, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) {
268-
return date
269-
}
270-
} else {
271-
let formatterWithSeconds = ISO8601DateFormatter()
272-
formatterWithSeconds.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
273-
if let date = formatterWithSeconds.date(from: string) {
274-
return date
275-
}
276-
let formatterWithoutSeconds = ISO8601DateFormatter()
277-
formatterWithoutSeconds.formatOptions = [.withFullDate, .withFullTime]
278-
if let date = formatterWithoutSeconds.date(from: string) {
279-
return date
280-
}
281-
}
282-
#endif
283-
return nil
284-
}
285-
286-
func formatISO8601Date(_ date: Date) -> String? {
287-
#if canImport(FoundationEssentials)
288-
return date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true))
289-
#else
290-
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
291-
return date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true))
292-
} else {
293-
let formatterWithSeconds = ISO8601DateFormatter()
294-
formatterWithSeconds.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
295-
return formatterWithSeconds.string(from: date)
296-
}
297-
#endif
298-
}
299249
}

0 commit comments

Comments
 (0)