From a03697a40e53c83fe712cc4a9ad03c8d576b0d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 27 May 2026 16:49:33 +0700 Subject: [PATCH] fix(connections): read ~/.aws/config and support credential_process for AWS IAM auth --- CHANGELOG.md | 1 + TablePro/Core/Database/AWS/AWSAuthError.swift | 33 +++- .../Database/AWS/AWSCredentialResolver.swift | 175 ++++++++++++++++-- TableProTests/AWS/AWSIAMAuthTests.swift | 73 +++++++- docs/databases/mysql.mdx | 2 +- docs/databases/postgresql.mdx | 2 +- 6 files changed, 268 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63029bac1..2ede2dd34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Database/AWS/AWSAuthError.swift b/TablePro/Core/Database/AWS/AWSAuthError.swift index aac89b9ab..64a95b3cf 100644 --- a/TablePro/Core/Database/AWS/AWSAuthError.swift +++ b/TablePro/Core/Database/AWS/AWSAuthError.swift @@ -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 { @@ -19,7 +24,7 @@ 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): @@ -27,6 +32,32 @@ enum AWSAuthError: Error, LocalizedError, Equatable { 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 + ) } } } diff --git a/TablePro/Core/Database/AWS/AWSCredentialResolver.swift b/TablePro/Core/Database/AWS/AWSCredentialResolver.swift index d2422d6bb..a75fa272e 100644 --- a/TablePro/Core/Database/AWS/AWSCredentialResolver.swift +++ b/TablePro/Core/Database/AWS/AWSCredentialResolver.swift @@ -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: @@ -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) 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 ) } diff --git a/TableProTests/AWS/AWSIAMAuthTests.swift b/TableProTests/AWS/AWSIAMAuthTests.swift index 4048c4acd..3ae710431 100644 --- a/TableProTests/AWS/AWSIAMAuthTests.swift +++ b/TableProTests/AWS/AWSIAMAuthTests.swift @@ -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( @@ -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" + ) + } + } +} diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 8fdbdd6db..446fcf7ba 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -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 ` 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. diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 681fbaf29..d9e6a7277 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -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 ` first. Set **Username** to a database role granted `rds_iam`. The **AWS Region** is detected from the RDS hostname and can be overridden.