Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- MongoDB: opening a collection no longer crashes when a document contains a NaN or infinite number. (#1418)
- Opening a saved connection that fails now shows the detailed troubleshooting dialog with suggested fixes, the same one Test Connection shows, instead of a generic error alert. (#1425, #483)
- Oracle connection errors no longer surface the driver's raw internal message; failures now explain the cause in plain language. (#483)
- AWS IAM authentication with a named profile now reads `~/.aws/config` (not just `~/.aws/credentials`) and supports `credential_process`, so profiles backed by SSO, IAM Identity Center, or assume-role work through `aws configure export-credentials`. (#1291)

## [0.45.0] - 2026-05-26

Expand Down
33 changes: 32 additions & 1 deletion TablePro/Core/Database/AWS/AWSAuthError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ enum AWSAuthError: Error, LocalizedError, Equatable {
case credentialsFileUnreadable
case profileIncomplete(String)
case regionUnknown(host: String)
case credentialProcessInvalid(String)
case credentialProcessLaunchFailed(profile: String, underlying: String)
case credentialProcessFailed(profile: String, status: Int, message: String)
case credentialProcessBadOutput(String)
case credentialProcessUnsupportedVersion(profile: String, version: Int)

var errorDescription: String? {
switch self {
Expand All @@ -19,14 +24,40 @@ enum AWSAuthError: Error, LocalizedError, Equatable {
return String(localized: "Cannot read ~/.aws/credentials.")
case .profileIncomplete(let profile):
return String(
format: String(localized: "Profile \"%@\" was not found or is missing keys in ~/.aws/credentials."),
format: String(localized: "Profile \"%@\" was not found, or has no access keys or credential_process, in ~/.aws/config or ~/.aws/credentials."),
profile
)
case .regionUnknown(let host):
return String(
format: String(localized: "Could not determine an AWS region for \"%@\". Set the AWS Region field."),
host
)
case .credentialProcessInvalid(let profile):
return String(
format: String(localized: "The credential_process command for profile \"%@\" is empty or invalid."),
profile
)
case .credentialProcessLaunchFailed(let profile, let underlying):
return String(
format: String(localized: "Could not run the credential_process command for profile \"%@\": %@"),
profile, underlying
)
case .credentialProcessFailed(let profile, let status, let message):
let detail = message.isEmpty ? "" : "\n\(message)"
return String(
format: String(localized: "The credential_process command for profile \"%@\" exited with status %lld.%@"),
profile, status, detail
)
case .credentialProcessBadOutput(let profile):
return String(
format: String(localized: "The credential_process command for profile \"%@\" did not return valid credentials JSON."),
profile
)
case .credentialProcessUnsupportedVersion(let profile, let version):
return String(
format: String(localized: "The credential_process command for profile \"%@\" returned unsupported Version %lld (expected 1)."),
profile, version
)
}
}
}
175 changes: 161 additions & 14 deletions TablePro/Core/Database/AWS/AWSCredentialResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ enum AWSCredentialResolver {
static func resolve(source: String, fields: [String: String]) async throws -> AWSCredentials {
switch source {
case "profile":
return try resolveProfile(fields: fields)
return try await resolveProfile(fields: fields)
case "sso":
return try await resolveSSO(fields: fields)
default:
Expand All @@ -33,29 +33,176 @@ enum AWSCredentialResolver {
)
}

private static func resolveProfile(fields: [String: String]) throws -> AWSCredentials {
private static func resolveProfile(fields: [String: String]) async throws -> AWSCredentials {
let profileName = fields["awsProfileName"].flatMap { $0.isEmpty ? nil : $0 } ?? "default"
let settings = profileSettings(profileName: profileName)
guard !settings.isEmpty else {
throw AWSAuthError.profileIncomplete(profileName)
}

let accessKeyId = settings["aws_access_key_id"] ?? ""
let secretAccessKey = settings["aws_secret_access_key"] ?? ""
if !accessKeyId.isEmpty, !secretAccessKey.isEmpty {
let sessionToken = settings["aws_session_token"]
return AWSCredentials(
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: sessionToken?.isEmpty == true ? nil : sessionToken
)
}

if let command = settings["credential_process"], !command.isEmpty {
return try await runCredentialProcess(command, profileName: profileName)
}

throw AWSAuthError.profileIncomplete(profileName)
}

private static func profileSettings(profileName: String) -> [String: String] {
var settings: [String: String] = [:]

let configPath = NSString("~/.aws/config").expandingTildeInPath
if let content = try? String(contentsOfFile: configPath, encoding: .utf8) {
let sections = AWSSSO.parseIniSections(content)
let sectionKey = profileName == "default" ? "default" : "profile \(profileName)"
if let section = sections[sectionKey] {
settings.merge(section) { _, new in new }
}
}

let credentialsPath = NSString("~/.aws/credentials").expandingTildeInPath
if let content = try? String(contentsOfFile: credentialsPath, encoding: .utf8) {
let sections = AWSSSO.parseIniSections(content)
if let section = sections[profileName] {
settings.merge(section) { _, new in new }
}
}

return settings
}

guard let content = try? String(contentsOfFile: credentialsPath, encoding: .utf8) else {
throw AWSAuthError.credentialsFileUnreadable
private static func runCredentialProcess(_ command: String, profileName: String) async throws -> AWSCredentials {
let arguments = tokenizeCommand(command)
guard !arguments.isEmpty else {
throw AWSAuthError.credentialProcessInvalid(profileName)
}

let sections = AWSSSO.parseIniSections(content)
guard let profile = sections[profileName] else {
throw AWSAuthError.profileIncomplete(profileName)
let output = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
DispatchQueue.global(qos: .userInitiated).async {
do {
continuation.resume(returning: try executeCredentialProcess(arguments, profileName: profileName))
} catch {
continuation.resume(throwing: error)
}
}
}

let accessKeyId = profile["aws_access_key_id"] ?? ""
let secretAccessKey = profile["aws_secret_access_key"] ?? ""
guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else {
throw AWSAuthError.profileIncomplete(profileName)
return try parseCredentialProcessOutput(output, profileName: profileName)
}

private static func executeCredentialProcess(_ arguments: [String], profileName: String) throws -> Data {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = arguments
process.environment = processEnvironment()

let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe

do {
try process.run()
} catch {
throw AWSAuthError.credentialProcessLaunchFailed(
profile: profileName,
underlying: error.localizedDescription
)
}

let output = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorOutput = errorPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()

guard process.terminationStatus == 0 else {
let message = String(data: errorOutput, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
throw AWSAuthError.credentialProcessFailed(
profile: profileName,
status: Int(process.terminationStatus),
message: message
)
}

return output
}

private static func processEnvironment() -> [String: String] {
var environment = ProcessInfo.processInfo.environment
let searchPaths = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
let inherited = environment["PATH"].map { [$0] } ?? []
environment["PATH"] = (searchPaths + inherited).joined(separator: ":")
return environment
}

static func tokenizeCommand(_ command: String) -> [String] {
var tokens: [String] = []
var current = ""
var inQuotes = false
var hasToken = false

for character in command {
switch character {
case "\"":
inQuotes.toggle()
hasToken = true
case " " where !inQuotes:
if hasToken {
tokens.append(current)
current = ""
hasToken = false
}
default:
current.append(character)
hasToken = true
}
}

if hasToken {
tokens.append(current)
}

return tokens
}

private struct CredentialProcessOutput: Decodable {
let version: Int
let accessKeyId: String
let secretAccessKey: String
let sessionToken: String?

enum CodingKeys: String, CodingKey {
case version = "Version"
case accessKeyId = "AccessKeyId"
case secretAccessKey = "SecretAccessKey"
case sessionToken = "SessionToken"
}
}

static func parseCredentialProcessOutput(_ data: Data, profileName: String) throws -> AWSCredentials {
guard let output = try? JSONDecoder().decode(CredentialProcessOutput.self, from: data) else {
throw AWSAuthError.credentialProcessBadOutput(profileName)
}
guard output.version == 1 else {
throw AWSAuthError.credentialProcessUnsupportedVersion(profile: profileName, version: output.version)
}
guard !output.accessKeyId.isEmpty, !output.secretAccessKey.isEmpty else {
throw AWSAuthError.credentialProcessBadOutput(profileName)
}
return AWSCredentials(
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: profile["aws_session_token"]
accessKeyId: output.accessKeyId,
secretAccessKey: output.secretAccessKey,
sessionToken: output.sessionToken?.isEmpty == true ? nil : output.sessionToken
)
}

Expand Down
73 changes: 72 additions & 1 deletion TableProTests/AWS/AWSIAMAuthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ struct RDSAuthTokenGeneratorTests {
private func makeToken(sessionToken: String? = nil) -> String {
RDSAuthTokenGenerator.generateToken(
host: "mydb.us-east-1.rds.amazonaws.com",
port: 5432,
port: 5_432,
region: "us-east-1",
username: "iam_user",
credentials: AWSCredentials(
Expand Down Expand Up @@ -227,3 +227,74 @@ struct RegistryAWSIAMFieldsTests {
#expect(!fieldIds(forTypeId: "CockroachDB").contains("awsAuth"))
}
}

@Suite("AWS credential_process")
struct AWSCredentialProcessTests {
@Test("Tokenizes a plain command into arguments")
func tokenizePlain() {
let argv = AWSCredentialResolver.tokenizeCommand(
"aws configure export-credentials --profile c9 --format process"
)
#expect(argv == ["aws", "configure", "export-credentials", "--profile", "c9", "--format", "process"])
}

@Test("Keeps double-quoted arguments that contain spaces intact")
func tokenizeQuoted() {
let argv = AWSCredentialResolver.tokenizeCommand("\"/Users/Dave/path to/creds.sh\" plain \"arg with spaces\"")
#expect(argv == ["/Users/Dave/path to/creds.sh", "plain", "arg with spaces"])
}

@Test("Collapses repeated spaces and returns empty for blank input")
func tokenizeEdges() {
#expect(AWSCredentialResolver.tokenizeCommand(" aws sts ") == ["aws", "sts"])
#expect(AWSCredentialResolver.tokenizeCommand("").isEmpty)
#expect(AWSCredentialResolver.tokenizeCommand(" ").isEmpty)
}

private func output(_ json: String) -> Data { Data(json.utf8) }

@Test("Parses Version 1 output with a session token")
func parseTemporary() throws {
let creds = try AWSCredentialResolver.parseCredentialProcessOutput(
output(
#"{"Version":1,"AccessKeyId":"AKID","SecretAccessKey":"SECRET","SessionToken":"TOKEN","Expiration":"2026-01-01T00:00:00Z"}"#
),
profileName: "c9"
)
#expect(creds.accessKeyId == "AKID")
#expect(creds.secretAccessKey == "SECRET")
#expect(creds.sessionToken == "TOKEN")
}

@Test("Parses long-term output without a session token")
func parseLongTerm() throws {
let creds = try AWSCredentialResolver.parseCredentialProcessOutput(
output(#"{"Version":1,"AccessKeyId":"AKID","SecretAccessKey":"SECRET"}"#),
profileName: "c9"
)
#expect(creds.sessionToken == nil)
}

@Test("Rejects a Version other than 1")
func parseUnsupportedVersion() {
#expect(throws: AWSAuthError.credentialProcessUnsupportedVersion(profile: "c9", version: 2)) {
_ = try AWSCredentialResolver.parseCredentialProcessOutput(
output(#"{"Version":2,"AccessKeyId":"AKID","SecretAccessKey":"SECRET"}"#),
profileName: "c9"
)
}
}

@Test("Rejects malformed or incomplete output")
func parseBadOutput() {
#expect(throws: AWSAuthError.credentialProcessBadOutput("c9")) {
_ = try AWSCredentialResolver.parseCredentialProcessOutput(output("not json"), profileName: "c9")
}
#expect(throws: AWSAuthError.credentialProcessBadOutput("c9")) {
_ = try AWSCredentialResolver.parseCredentialProcessOutput(
output(#"{"Version":1,"AccessKeyId":"","SecretAccessKey":"SECRET"}"#),
profileName: "c9"
)
}
}
}
2 changes: 1 addition & 1 deletion docs/databases/mysql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Open URLs like `mysql://user:pass@host/db` from your browser to connect directly
Connect to RDS or Aurora with IAM database authentication instead of a static password. Set **Authentication** in the connection form to one of the AWS IAM options:

- **AWS IAM (Access Key)**: enter an access key ID, secret access key, and optional session token.
- **AWS IAM (Profile)**: use a named profile from `~/.aws/credentials`.
- **AWS IAM (Profile)**: use a named profile from `~/.aws/credentials` or `~/.aws/config`. Profiles that use `credential_process` work too, so you can back the profile with SSO or assume-role via `aws configure export-credentials`.
- **AWS IAM (SSO)**: use a profile backed by IAM Identity Center. Run `aws sso login --profile <name>` first.

Set **Username** to a database user created with the AWS authentication plugin. The **AWS Region** is detected from the RDS hostname and can be overridden.
Expand Down
2 changes: 1 addition & 1 deletion docs/databases/postgresql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Open URLs like `postgresql://user:pass@host/db` directly to connect. See [Connec
Connect to RDS or Aurora with IAM database authentication instead of a static password. Set **Authentication** in the connection form to one of the AWS IAM options:

- **AWS IAM (Access Key)**: enter an access key ID, secret access key, and optional session token.
- **AWS IAM (Profile)**: use a named profile from `~/.aws/credentials`.
- **AWS IAM (Profile)**: use a named profile from `~/.aws/credentials` or `~/.aws/config`. Profiles that use `credential_process` work too, so you can back the profile with SSO or assume-role via `aws configure export-credentials`.
- **AWS IAM (SSO)**: use a profile backed by IAM Identity Center. Run `aws sso login --profile <name>` first.

Set **Username** to a database role granted `rds_iam`. The **AWS Region** is detected from the RDS hostname and can be overridden.
Expand Down
Loading