diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd819715..3bd649285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import. - Oracle connections negotiate Native Network Encryption when the server asks for it, so servers with `SQLNET.ENCRYPTION_SERVER` or `SQLNET.CRYPTO_CHECKSUM_SERVER` set to REQUIRED now connect (AES with a SHA crypto-checksum), matching what SQL Developer and DBeaver do. (#483) - Oracle connections follow listener redirects, so RAC SCAN listeners, shared server, and load-balanced setups now connect instead of failing during the handshake. (#483) +- AWS connections can assume an IAM role: profiles with `role_arn` plus `source_profile` (or `credential_source = Environment`) are resolved through STS AssumeRole, including chained source profiles, `external_id`, and `duration_seconds`. (#1567) +- Redis connects to Amazon ElastiCache with IAM auth (access key, profile, or SSO). TablePro generates the IAM token and uses it as the password; set the AWS region and cache name and enable TLS. (#1567) +- AWS SSO connections can sign in from TablePro: when the SSO session has expired, a prompt opens the AWS sign-in page in your browser and refreshes the cached token. (#1567) +- Cassandra connects to Amazon Keyspaces with AWS IAM (SigV4) auth, using access keys, a profile, or SSO. Set Authentication to an AWS IAM mode and the region, and enable TLS. (#1567) ### Changed @@ -23,9 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cmd+N now opens a new connection; Manage Connections keeps its File menu item. - First Page and Last Page now default to Cmd+Option+Up and Cmd+Option+Down. - Shortcuts can be bound to function keys (F1 through F12), with or without a modifier. +- AWS connections show a dropdown of the profiles found in `~/.aws/config` and `~/.aws/credentials`, and still accept a typed profile name. (#1567) ### Fixed +- DynamoDB AWS Profile auth now reads `~/.aws/config` as well as `~/.aws/credentials` and supports `credential_process`, matching the AWS CLI. Profiles defined only in `~/.aws/config`, including SSO and credential-process profiles, no longer fail with "profile not found". (#1567) - Query result columns now follow the order in the SELECT. Adding or removing a column no longer leaves new columns stuck at the end of the grid. (#1565) - JSON file import works again. It failed to load in 0.48.0. - SQL export quotes empty or malformed values in numeric columns instead of writing them unquoted, which could produce invalid INSERT statements. diff --git a/Plugins/CassandraDriverPlugin/CassandraConnection.swift b/Plugins/CassandraDriverPlugin/CassandraConnection.swift index b80b97e67..f3124bacc 100644 --- a/Plugins/CassandraDriverPlugin/CassandraConnection.swift +++ b/Plugins/CassandraDriverPlugin/CassandraConnection.swift @@ -44,7 +44,9 @@ actor CassandraConnectionActor { sslCaCertPath: String?, sslClientCertPath: String?, sslClientKeyPath: String?, - sslClientKeyPassphrase: String? + sslClientKeyPassphrase: String?, + awsCredentials: AWSCredentials? = nil, + awsRegion: String? = nil ) throws { cluster = cass_cluster_new() guard let cluster else { @@ -54,7 +56,9 @@ actor CassandraConnectionActor { cass_cluster_set_contact_points(cluster, host) cass_cluster_set_port(cluster, Int32(port)) - if let username, !username.isEmpty, let password { + if let awsCredentials, let awsRegion, !awsRegion.isEmpty { + CassandraSigV4Authenticator.apply(to: cluster, credentials: awsCredentials, region: awsRegion) + } else if let username, !username.isEmpty, let password { cass_cluster_set_credentials(cluster, username, password) } diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index f72a3a2fe..3ad9a45f8 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -25,7 +25,7 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseDisplayName = "Cassandra / ScyllaDB" static let iconName = "cassandra-icon" static let defaultPort = 9042 - static let additionalConnectionFields: [ConnectionField] = [] + static let additionalConnectionFields: [ConnectionField] = AWSAuthFields.standard() static let additionalDatabaseTypeIds: [String] = ["ScyllaDB"] // MARK: - UI/Capability Metadata @@ -155,6 +155,22 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen let clientKeyPath = config.ssl.clientKeyPath.isEmpty ? nil : config.ssl.clientKeyPath let clientKeyPassphrase = config.additionalFields["sslClientKeyPassphrase"] + var awsCredentials: AWSCredentials? + var awsRegion: String? + let awsAuth = config.additionalFields["awsAuth"] ?? "off" + if awsAuth != "off", !awsAuth.isEmpty { + guard let region = config.additionalFields["awsRegion"].flatMap({ $0.isEmpty ? nil : $0 }) else { + throw AWSAuthError.regionUnknown(host: config.host) + } + guard config.ssl.mode != .disabled else { + throw AWSAuthError.missingConfiguration( + String(localized: "Amazon Keyspaces IAM authentication requires TLS. Enable SSL in the connection's SSL settings.") + ) + } + awsRegion = region + awsCredentials = try await AWSCredentialResolver.resolve(source: awsAuth, fields: config.additionalFields) + } + try await connectionActor.connect( host: config.host, port: Int(config.port) ?? 9_042, @@ -165,7 +181,9 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen sslCaCertPath: resolvedCaPath, sslClientCertPath: clientCertPath, sslClientKeyPath: clientKeyPath, - sslClientKeyPassphrase: clientKeyPassphrase + sslClientKeyPassphrase: clientKeyPassphrase, + awsCredentials: awsCredentials, + awsRegion: awsRegion ) if let keyspace { diff --git a/Plugins/CassandraDriverPlugin/CassandraSigV4Authenticator.swift b/Plugins/CassandraDriverPlugin/CassandraSigV4Authenticator.swift new file mode 100644 index 000000000..e5324f3c8 --- /dev/null +++ b/Plugins/CassandraDriverPlugin/CassandraSigV4Authenticator.swift @@ -0,0 +1,66 @@ +import CCassandra +import Foundation +import TableProPluginKit + +final class CassandraSigV4Context { + let credentials: AWSCredentials + let region: String + + init(credentials: AWSCredentials, region: String) { + self.credentials = credentials + self.region = region + } +} + +enum CassandraSigV4Authenticator { + static func apply(to cluster: OpaquePointer, credentials: AWSCredentials, region: String) { + var callbacks = CassAuthenticatorCallbacks( + initial_callback: cassandraSigV4Initial, + challenge_callback: cassandraSigV4Challenge, + success_callback: nil, + cleanup_callback: nil + ) + let context = CassandraSigV4Context(credentials: credentials, region: region) + let data = Unmanaged.passRetained(context).toOpaque() + cass_cluster_set_authenticator_callbacks(cluster, &callbacks, cassandraSigV4DataCleanup, data) + } +} + +private func cassandraSigV4Initial(_ auth: OpaquePointer?, _ data: UnsafeMutableRawPointer?) { + let bytes = Array(KeyspacesSigV4.initialResponse.utf8) + bytes.withUnsafeBytes { raw in + cass_authenticator_set_response(auth, raw.bindMemory(to: CChar.self).baseAddress, bytes.count) + } +} + +private func cassandraSigV4Challenge( + _ auth: OpaquePointer?, + _ data: UnsafeMutableRawPointer?, + _ token: UnsafePointer?, + _ tokenSize: Int +) { + guard let data else { return } + let context = Unmanaged.fromOpaque(data).takeUnretainedValue() + + let challenge: Data + if let token, tokenSize > 0 { + challenge = Data(bytes: token, count: tokenSize) + } else { + challenge = Data() + } + guard let nonce = KeyspacesSigV4.nonce(fromChallenge: challenge) else { return } + + let response = KeyspacesSigV4.authResponse( + nonce: nonce, + credentials: context.credentials, + region: context.region + ) + response.withCString { pointer in + cass_authenticator_set_response(auth, pointer, strlen(pointer)) + } +} + +private func cassandraSigV4DataCleanup(_ data: UnsafeMutableRawPointer?) { + guard let data else { return } + Unmanaged.fromOpaque(data).release() +} diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift index 76f060ad6..8c1e16de7 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift @@ -112,14 +112,6 @@ private enum DynamoDBTypeCodingKey: String, CodingKey { case bs = "BS" } -// MARK: - AWS Credentials - -internal struct AWSCredentials: Sendable { - let accessKeyId: String - let secretAccessKey: String - let sessionToken: String? -} - // MARK: - DynamoDB Error internal enum DynamoDBError: Error, LocalizedError { @@ -414,11 +406,11 @@ internal final class DynamoDBConnection: @unchecked Sendable { // MARK: - Internal Request Handling private func request(target: String, body: [String: Any]) async throws -> T { - let (urlSession, credentials): (URLSession, AWSCredentials) = try lock.withLock { + let urlSession: URLSession = try lock.withLock { guard let s = _session else { throw DynamoDBError.notConnected } - guard let c = _credentials else { throw DynamoDBError.authFailed("No credentials available") } - return (s, c) + return s } + let credentials = try await validCredentials() let bodyData = try JSONSerialization.data(withJSONObject: body, options: [.sortedKeys]) @@ -598,120 +590,42 @@ internal final class DynamoDBConnection: @unchecked Sendable { // MARK: - Credential Resolution private func resolveCredentials() async throws -> AWSCredentials { - let authMethod = config.additionalFields["awsAuthMethod"] ?? "credentials" - - switch authMethod { - case "credentials": - return try resolveAccessKeyCredentials() - case "profile": - return try resolveProfileCredentials() - case "sso": - return try await resolveSsoCredentials() - default: - return try resolveAccessKeyCredentials() - } - } - - private func resolveAccessKeyCredentials() throws -> AWSCredentials { - let accessKeyId = config.additionalFields["awsAccessKeyId"] ?? config.username - let secretAccessKey = config.additionalFields["awsSecretAccessKey"] ?? config.password - let sessionToken = config.additionalFields["awsSessionToken"] - - Self.logger.debug("Resolved credentials — credentialSource: accessKey, region: \(self.region)") - - guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else { - throw DynamoDBError.authFailed("Access Key ID and Secret Access Key are required") + let source = Self.credentialSource(forAuthMethod: config.additionalFields["awsAuthMethod"]) + var fields = config.additionalFields + if (fields["awsAccessKeyId"] ?? "").isEmpty, !config.username.isEmpty { + fields["awsAccessKeyId"] = config.username } - - return AWSCredentials( - accessKeyId: accessKeyId, - secretAccessKey: secretAccessKey, - sessionToken: sessionToken?.isEmpty == true ? nil : sessionToken - ) - } - - private func resolveProfileCredentials() throws -> AWSCredentials { - let profileName = config.additionalFields["awsProfileName"] ?? "default" - let credentialsPath = NSString("~/.aws/credentials").expandingTildeInPath - - guard let content = try? String(contentsOfFile: credentialsPath, encoding: .utf8) else { - throw DynamoDBError.authFailed("Cannot read ~/.aws/credentials") + if (fields["awsSecretAccessKey"] ?? "").isEmpty, !config.password.isEmpty { + fields["awsSecretAccessKey"] = config.password } - var currentProfile = "" - var accessKeyId = "" - var secretAccessKey = "" - var sessionToken: String? - - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - currentProfile = String(trimmed.dropFirst().dropLast()) - continue - } - guard currentProfile == profileName else { continue } - - let parts = trimmed.split(separator: "=", maxSplits: 1).map { - $0.trimmingCharacters(in: .whitespaces) - } - guard parts.count == 2 else { continue } - - switch parts[0] { - case "aws_access_key_id": - accessKeyId = parts[1] - case "aws_secret_access_key": - secretAccessKey = parts[1] - case "aws_session_token": - sessionToken = parts[1] - default: - break - } - } - - guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else { - throw DynamoDBError.authFailed("Profile '\(profileName)' not found or incomplete in ~/.aws/credentials") + do { + return try await AWSCredentialResolver.resolve(source: source, fields: fields) + } catch let error as AWSAuthError { + throw DynamoDBError.authFailed(error.localizedDescription) + } catch let error as AWSSSOError { + throw DynamoDBError.authFailed(error.localizedDescription) } - - return AWSCredentials( - accessKeyId: accessKeyId, - secretAccessKey: secretAccessKey, - sessionToken: sessionToken - ) } - private func resolveSsoCredentials() async throws -> AWSCredentials { - let profileName = config.additionalFields["awsProfileName"] ?? "default" - let configPath = NSString("~/.aws/config").expandingTildeInPath - let cacheDir = NSString("~/.aws/sso/cache").expandingTildeInPath - - guard let configContent = try? String(contentsOfFile: configPath, encoding: .utf8) else { - throw DynamoDBError.authFailed(SsoCredentialError.configReadFailed.userMessage) + static func credentialSource(forAuthMethod authMethod: String?) -> String { + switch authMethod { + case "profile": + return "profile" + case "sso": + return "sso" + default: + return "accessKey" } + } - do { - let settings = try DynamoDBSso.parseProfileSettings( - configContent: configContent, - profileName: profileName - ) - let accessToken = try DynamoDBSso.readAccessToken( - cacheDirectory: cacheDir, - settings: settings, - profileName: profileName - ) - let credentials = try await DynamoDBSso.fetchRoleCredentials( - accessToken: accessToken, - settings: settings, - profileName: profileName, - session: URLSession.shared - ) - return AWSCredentials( - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken - ) - } catch let error as SsoCredentialError { - throw DynamoDBError.authFailed(error.userMessage) + private func validCredentials() async throws -> AWSCredentials { + if let current = lock.withLock({ _credentials }), !current.isExpired() { + return current } + let refreshed = try await resolveCredentials() + lock.withLock { _credentials = refreshed } + return refreshed } // MARK: - Helpers diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift index fa84cec47..e3f250b8a 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift @@ -113,7 +113,7 @@ final class DynamoDBPlugin: NSObject, TableProPlugin, DriverPlugin { placeholder: "default", section: .authentication, visibleWhen: FieldVisibilityRule(fieldId: "awsAuthMethod", values: ["profile", "sso"]) - ), + ).withDynamicOptions(.awsProfiles), ConnectionField( id: "awsRegion", label: String(localized: "AWS Region"), diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift deleted file mode 100644 index 752a34492..000000000 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift +++ /dev/null @@ -1,272 +0,0 @@ -// -// DynamoDBSsoCredentials.swift -// DynamoDBDriverPlugin -// -// AWS SSO credential resolution: reads the OIDC access token from -// ~/.aws/sso/cache/ and exchanges it for STS credentials via the SSO portal -// GetRoleCredentials endpoint. Matches the flow used by AWS SDKs. -// - -import CommonCrypto -import Foundation - -struct SsoProfileSettings: Equatable, Sendable { - let accountId: String - let roleName: String - let startUrl: String - let region: String - let ssoSession: String? -} - -struct SsoRoleCredentials: Equatable, Sendable { - let accessKeyId: String - let secretAccessKey: String - let sessionToken: String -} - -enum SsoCredentialError: Error, Equatable { - case configReadFailed - case profileNotFound(String) - case profileMissingFields(profile: String) - case sessionNotFound(profile: String, session: String) - case sessionMissingFields(session: String) - case profileMissingUrlOrRegion(String) - case tokenCacheNotFound(profile: String) - case tokenCacheMalformed(profile: String) - case tokenExpired(profile: String) - case urlBuildFailed(profile: String) - case networkFailure(profile: String, underlying: String) - case invalidResponse(profile: String) - case sessionUnauthorized(profile: String) - case roleNotAccessible(role: String, account: String) - case portalError(profile: String, status: Int) - case responseDecodeFailed(profile: String) - case credentialsAlreadyExpired(profile: String) - - var userMessage: String { - switch self { - case .configReadFailed: - return "Cannot read ~/.aws/config" - case .profileNotFound(let profile): - return "Profile '\(profile)' not found in ~/.aws/config" - case .profileMissingFields(let profile): - return "Profile '\(profile)' in ~/.aws/config is missing sso_account_id or sso_role_name" - case .sessionNotFound(let profile, let session): - return "SSO session '\(session)' referenced by profile '\(profile)' not found in ~/.aws/config" - case .sessionMissingFields(let session): - return "SSO session '\(session)' in ~/.aws/config is missing sso_start_url or sso_region" - case .profileMissingUrlOrRegion(let profile): - return "Profile '\(profile)' in ~/.aws/config is missing sso_start_url or sso_region (required for legacy SSO)" - case .tokenCacheNotFound(let profile): - return "SSO token cache not found for profile '\(profile)'. Run 'aws sso login --profile \(profile)' first." - case .tokenCacheMalformed(let profile): - return "SSO token cache for profile '\(profile)' is malformed. Run 'aws sso login --profile \(profile)' to refresh." - case .tokenExpired(let profile), .sessionUnauthorized(let profile): - return "SSO session for profile '\(profile)' has expired. Run 'aws sso login --profile \(profile)' to refresh." - case .urlBuildFailed(let profile): - return "Failed to build SSO portal URL for profile '\(profile)'" - case .networkFailure(let profile, let underlying): - return "Failed to reach SSO portal for profile '\(profile)': \(underlying)" - case .invalidResponse(let profile): - return "Unexpected response from SSO portal for profile '\(profile)'" - case .roleNotAccessible(let role, let account): - return "Role '\(role)' in account '\(account)' is not accessible via SSO. Check role permissions in AWS IAM Identity Center." - case .portalError(let profile, let status): - return "SSO portal returned HTTP \(status) for profile '\(profile)'" - case .responseDecodeFailed(let profile): - return "Failed to decode SSO portal response for profile '\(profile)'" - case .credentialsAlreadyExpired(let profile): - return "SSO role credentials for profile '\(profile)' were already expired on arrival. Run 'aws sso login --profile \(profile)' to refresh." - } - } -} - -enum DynamoDBSso { - static func parseIniSections(_ content: String) -> [String: [String: String]] { - var sections: [String: [String: String]] = [:] - var current = "" - - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty || trimmed.hasPrefix("#") || trimmed.hasPrefix(";") { continue } - - if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - current = String(trimmed.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) - if sections[current] == nil { - sections[current] = [:] - } - continue - } - - guard !current.isEmpty else { continue } - - let parts = trimmed.split(separator: "=", maxSplits: 1).map { - $0.trimmingCharacters(in: .whitespaces) - } - guard parts.count == 2 else { continue } - - sections[current, default: [:]][parts[0]] = parts[1] - } - - return sections - } - - static func parseProfileSettings(configContent: String, profileName: String) throws -> SsoProfileSettings { - let sections = parseIniSections(configContent) - let profileSection = profileName == "default" ? "default" : "profile \(profileName)" - - guard let profile = sections[profileSection] else { - throw SsoCredentialError.profileNotFound(profileName) - } - - guard let accountId = profile["sso_account_id"], let roleName = profile["sso_role_name"] else { - throw SsoCredentialError.profileMissingFields(profile: profileName) - } - - let ssoSession = profile["sso_session"] - let resolvedStartUrl: String - let resolvedRegion: String - - if let sessionName = ssoSession { - guard let session = sections["sso-session \(sessionName)"] else { - throw SsoCredentialError.sessionNotFound(profile: profileName, session: sessionName) - } - guard let startUrl = session["sso_start_url"], let region = session["sso_region"] else { - throw SsoCredentialError.sessionMissingFields(session: sessionName) - } - resolvedStartUrl = startUrl - resolvedRegion = region - } else { - guard let startUrl = profile["sso_start_url"], let region = profile["sso_region"] else { - throw SsoCredentialError.profileMissingUrlOrRegion(profileName) - } - resolvedStartUrl = startUrl - resolvedRegion = region - } - - return SsoProfileSettings( - accountId: accountId, - roleName: roleName, - startUrl: resolvedStartUrl, - region: resolvedRegion, - ssoSession: ssoSession - ) - } - - static func readAccessToken( - cacheDirectory: String, - settings: SsoProfileSettings, - profileName: String, - now: Date = Date() - ) throws -> String { - let cacheKey = settings.ssoSession ?? settings.startUrl - let cacheFileName = sha1Hex(Data(cacheKey.utf8)) + ".json" - let cacheFilePath = (cacheDirectory as NSString).appendingPathComponent(cacheFileName) - - guard let data = FileManager.default.contents(atPath: cacheFilePath) else { - throw SsoCredentialError.tokenCacheNotFound(profile: profileName) - } - - struct TokenCache: Decodable { - let accessToken: String - let expiresAt: String - } - - let token: TokenCache - do { - token = try JSONDecoder().decode(TokenCache.self, from: data) - } catch { - throw SsoCredentialError.tokenCacheMalformed(profile: profileName) - } - - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let expiresAt = formatter.date(from: token.expiresAt) ?? ISO8601DateFormatter().date(from: token.expiresAt) - if let expiresAt, expiresAt <= now { - throw SsoCredentialError.tokenExpired(profile: profileName) - } - - return token.accessToken - } - - static func fetchRoleCredentials( - accessToken: String, - settings: SsoProfileSettings, - profileName: String, - session: URLSession, - now: Date = Date() - ) async throws -> SsoRoleCredentials { - var components = URLComponents(string: "https://portal.sso.\(settings.region).amazonaws.com/federation/credentials") - components?.queryItems = [ - URLQueryItem(name: "account_id", value: settings.accountId), - URLQueryItem(name: "role_name", value: settings.roleName) - ] - guard let url = components?.url else { - throw SsoCredentialError.urlBuildFailed(profile: profileName) - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue(accessToken, forHTTPHeaderField: "x-amz-sso_bearer_token") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: request) - } catch { - throw SsoCredentialError.networkFailure(profile: profileName, underlying: error.localizedDescription) - } - - guard let http = response as? HTTPURLResponse else { - throw SsoCredentialError.invalidResponse(profile: profileName) - } - - switch http.statusCode { - case 200: - break - case 401: - throw SsoCredentialError.sessionUnauthorized(profile: profileName) - case 403: - throw SsoCredentialError.roleNotAccessible(role: settings.roleName, account: settings.accountId) - default: - throw SsoCredentialError.portalError(profile: profileName, status: http.statusCode) - } - - struct RoleCredentialsEnvelope: Decodable { - struct RoleCredentials: Decodable { - let accessKeyId: String - let secretAccessKey: String - let sessionToken: String - let expiration: Int64 - } - let roleCredentials: RoleCredentials - } - - let envelope: RoleCredentialsEnvelope - do { - envelope = try JSONDecoder().decode(RoleCredentialsEnvelope.self, from: data) - } catch { - throw SsoCredentialError.responseDecodeFailed(profile: profileName) - } - - let expiry = Date(timeIntervalSince1970: TimeInterval(envelope.roleCredentials.expiration) / 1_000) - if expiry <= now { - throw SsoCredentialError.credentialsAlreadyExpired(profile: profileName) - } - - return SsoRoleCredentials( - accessKeyId: envelope.roleCredentials.accessKeyId, - secretAccessKey: envelope.roleCredentials.secretAccessKey, - sessionToken: envelope.roleCredentials.sessionToken - ) - } - - static func sha1Hex(_ data: Data) -> String { - var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) - data.withUnsafeBytes { ptr in - _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &hash) - } - return hash.map { String(format: "%02x", $0) }.joined() - } -} diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index 84f781c93..412db55dd 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -20,7 +20,7 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "Redis" static let databaseDisplayName = "Redis" static let iconName = "redis-icon" - static let defaultPort = 6379 + static let defaultPort = 6_379 static let additionalConnectionFields: [ConnectionField] = [ ConnectionField( id: "redisDatabase", @@ -35,7 +35,7 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { fieldType: .text, section: .advanced ), - ] + ] + AWSAuthFields.standard() + [AWSAuthFields.elastiCacheReplicationGroupField()] // MARK: - UI/Capability Metadata diff --git a/TablePro/Core/Database/AWS/AWSAuthError.swift b/Plugins/TableProPluginKit/AWS/AWSAuthError.swift similarity index 57% rename from TablePro/Core/Database/AWS/AWSAuthError.swift rename to Plugins/TableProPluginKit/AWS/AWSAuthError.swift index 64a95b3cf..7f5f40d4f 100644 --- a/TablePro/Core/Database/AWS/AWSAuthError.swift +++ b/Plugins/TableProPluginKit/AWS/AWSAuthError.swift @@ -5,7 +5,7 @@ import Foundation -enum AWSAuthError: Error, LocalizedError, Equatable { +public enum AWSAuthError: Error, LocalizedError, Equatable { case missingAccessKey case credentialsFileUnreadable case profileIncomplete(String) @@ -15,8 +15,15 @@ enum AWSAuthError: Error, LocalizedError, Equatable { case credentialProcessFailed(profile: String, status: Int, message: String) case credentialProcessBadOutput(String) case credentialProcessUnsupportedVersion(profile: String, version: Int) + case credentialProcessUnsupportedOnPlatform(String) + case assumeRoleMissingSource(String) + case assumeRoleChainTooDeep(String) + case assumeRoleFailed(role: String, message: String) + case mfaUnsupported(String) + case credentialSourceUnsupported(profile: String, source: String) + case missingConfiguration(String) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .missingAccessKey: return String(localized: "Access Key ID and Secret Access Key are required for AWS IAM authentication.") @@ -58,6 +65,38 @@ enum AWSAuthError: Error, LocalizedError, Equatable { format: String(localized: "The credential_process command for profile \"%@\" returned unsupported Version %lld (expected 1)."), profile, version ) + case .credentialProcessUnsupportedOnPlatform(let profile): + return String( + format: String(localized: "The credential_process command for profile \"%@\" is only supported on macOS."), + profile + ) + case .assumeRoleMissingSource(let profile): + return String( + format: String(localized: "Profile \"%@\" sets role_arn but has no source_profile or credential_source to provide base credentials."), + profile + ) + case .assumeRoleChainTooDeep(let profile): + return String( + format: String(localized: "Profile \"%@\" has a source_profile chain that is too long to resolve."), + profile + ) + case .assumeRoleFailed(let role, let message): + return String( + format: String(localized: "Could not assume role \"%@\": %@"), + role, message + ) + case .mfaUnsupported(let profile): + return String( + format: String(localized: "Profile \"%@\" requires an MFA token code, which is not supported yet. Use a profile without mfa_serial."), + profile + ) + case .credentialSourceUnsupported(let profile, let source): + return String( + format: String(localized: "Profile \"%@\" uses credential_source \"%@\", which is not supported on the desktop app."), + profile, source + ) + case .missingConfiguration(let message): + return message } } } diff --git a/Plugins/TableProPluginKit/AWS/AWSAuthFields.swift b/Plugins/TableProPluginKit/AWS/AWSAuthFields.swift new file mode 100644 index 000000000..b6f555758 --- /dev/null +++ b/Plugins/TableProPluginKit/AWS/AWSAuthFields.swift @@ -0,0 +1,69 @@ +import Foundation + +public enum AWSAuthFields { + public static func standard() -> [ConnectionField] { + [ + ConnectionField( + id: "awsAuth", + label: String(localized: "Authentication"), + defaultValue: "off", + fieldType: .dropdown(options: [ + .init(value: "off", label: String(localized: "Password")), + .init(value: "accessKey", label: String(localized: "AWS IAM (Access Key)")), + .init(value: "profile", label: String(localized: "AWS IAM (Profile)")), + .init(value: "sso", label: String(localized: "AWS IAM (SSO)")) + ]), + section: .authentication, + hidesPassword: true + ), + ConnectionField( + id: "awsRegion", + label: String(localized: "AWS Region"), + placeholder: "us-east-1", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey", "profile", "sso"]) + ), + ConnectionField( + id: "awsAccessKeyId", + label: String(localized: "Access Key ID"), + placeholder: "AKIA...", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsSecretAccessKey", + label: String(localized: "Secret Access Key"), + placeholder: "wJalr...", + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsSessionToken", + label: String(localized: "Session Token"), + placeholder: String(localized: "Optional, for temporary credentials"), + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsProfileName", + label: String(localized: "Profile Name"), + placeholder: "default", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["profile", "sso"]) + ).withDynamicOptions(.awsProfiles) + ] + } + + public static func elastiCacheReplicationGroupField() -> ConnectionField { + ConnectionField( + id: "awsReplicationGroupId", + label: String(localized: "Cache Name / Replication Group ID"), + placeholder: String(localized: "my-cache"), + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey", "profile", "sso"]) + ) + } +} diff --git a/Plugins/TableProPluginKit/AWS/AWSConfigFile.swift b/Plugins/TableProPluginKit/AWS/AWSConfigFile.swift new file mode 100644 index 000000000..f3030bcb1 --- /dev/null +++ b/Plugins/TableProPluginKit/AWS/AWSConfigFile.swift @@ -0,0 +1,83 @@ +import Foundation + +public enum AWSConfigFile { + public static var defaultConfigPath: String { + if let override = ProcessInfo.processInfo.environment["AWS_CONFIG_FILE"], !override.isEmpty { + return (override as NSString).expandingTildeInPath + } + return NSString("~/.aws/config").expandingTildeInPath + } + + public static var defaultCredentialsPath: String { + if let override = ProcessInfo.processInfo.environment["AWS_SHARED_CREDENTIALS_FILE"], !override.isEmpty { + return (override as NSString).expandingTildeInPath + } + return NSString("~/.aws/credentials").expandingTildeInPath + } + + public static func readFile(_ path: String) -> String? { + try? String(contentsOfFile: path, encoding: .utf8) + } + + public static func mergedProfileSettings( + profileName: String, + configContents: String?, + credentialsContents: String? + ) -> [String: String] { + var settings: [String: String] = [:] + + if let configContents { + let sections = AWSSSO.parseIniSections(configContents) + let sectionKey = profileName == "default" ? "default" : "profile \(profileName)" + if let section = sections[sectionKey] { + settings.merge(section) { _, new in new } + } + } + + if let credentialsContents { + let sections = AWSSSO.parseIniSections(credentialsContents) + if let section = sections[profileName] { + settings.merge(section) { _, new in new } + } + } + + return settings + } + + public static func discoverProfiles( + configContents: String?, + credentialsContents: String? + ) -> [String] { + var ordered: [String] = [] + var seen: Set = [] + + func add(_ name: String) { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !seen.contains(trimmed) else { return } + seen.insert(trimmed) + ordered.append(trimmed) + } + + if let configContents { + for section in AWSSSO.parseIniSections(configContents).keys { + if section == "default" { + add("default") + } else if section.hasPrefix("profile ") { + add(String(section.dropFirst("profile ".count))) + } + } + } + + if let credentialsContents { + for section in AWSSSO.parseIniSections(credentialsContents).keys where !section.hasPrefix("sso-session ") { + add(section) + } + } + + return ordered.sorted { lhs, rhs in + if lhs == "default" { return rhs != "default" } + if rhs == "default" { return false } + return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending + } + } +} diff --git a/TablePro/Core/Database/AWS/AWSCredentialResolver.swift b/Plugins/TableProPluginKit/AWS/AWSCredentialResolver.swift similarity index 58% rename from TablePro/Core/Database/AWS/AWSCredentialResolver.swift rename to Plugins/TableProPluginKit/AWS/AWSCredentialResolver.swift index a75fa272e..d744f6fed 100644 --- a/TablePro/Core/Database/AWS/AWSCredentialResolver.swift +++ b/Plugins/TableProPluginKit/AWS/AWSCredentialResolver.swift @@ -1,12 +1,7 @@ -// -// AWSCredentialResolver.swift -// TablePro -// - import Foundation -enum AWSCredentialResolver { - static func resolve(source: String, fields: [String: String]) async throws -> AWSCredentials { +public enum AWSCredentialResolver { + public static func resolve(source: String, fields: [String: String]) async throws -> AWSCredentials { switch source { case "profile": return try await resolveProfile(fields: fields) @@ -35,22 +30,43 @@ enum AWSCredentialResolver { 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) + return try await resolveProfileChain(profileName: profileName, depth: 0) + } + + private static func resolveProfileChain(profileName: String, depth: Int) async throws -> AWSCredentials { + guard depth < 5 else { + throw AWSAuthError.assumeRoleChainTooDeep(profileName) + } + + let settings = AWSConfigFile.mergedProfileSettings( + profileName: profileName, + configContents: AWSConfigFile.readFile(AWSConfigFile.defaultConfigPath), + credentialsContents: AWSConfigFile.readFile(AWSConfigFile.defaultCredentialsPath) + ) 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 roleArn = settings["role_arn"], !roleArn.isEmpty { + if let mfaSerial = settings["mfa_serial"], !mfaSerial.isEmpty { + throw AWSAuthError.mfaUnsupported(profileName) + } + let base = try await baseCredentials(for: settings, profileName: profileName, depth: depth) + return try await AWSSTS.assumeRole( + roleArn: roleArn, + roleSessionName: settings["role_session_name"] ?? defaultSessionName(for: profileName), + externalId: settings["external_id"], + durationSeconds: settings["duration_seconds"].flatMap(Int.init), + region: settings["region"] ?? "us-east-1", + baseCredentials: base, + session: URLSession.shared ) } + if let credentials = staticCredentials(from: settings) { + return credentials + } + if let command = settings["credential_process"], !command.isEmpty { return try await runCredentialProcess(command, profileName: profileName) } @@ -58,30 +74,61 @@ enum AWSCredentialResolver { 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 } - } + private static func baseCredentials( + for settings: [String: String], + profileName: String, + depth: Int + ) async throws -> AWSCredentials { + if let sourceProfile = settings["source_profile"], !sourceProfile.isEmpty { + return try await resolveProfileChain(profileName: sourceProfile, depth: depth + 1) } - - 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 } + if let credentialSource = settings["credential_source"], !credentialSource.isEmpty { + guard credentialSource == "Environment" else { + throw AWSAuthError.credentialSourceUnsupported(profile: profileName, source: credentialSource) } + return try environmentCredentials(profileName: profileName) } + throw AWSAuthError.assumeRoleMissingSource(profileName) + } + + private static func defaultSessionName(for profileName: String) -> String { + let allowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+=,.@-" + ) + let cleaned = String(profileName.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" }) + let trimmed = String(cleaned.prefix(50)) + return "tablepro-\(trimmed.isEmpty ? "session" : trimmed)" + } - return settings + private static func staticCredentials(from settings: [String: String]) -> AWSCredentials? { + let accessKeyId = settings["aws_access_key_id"] ?? "" + let secretAccessKey = settings["aws_secret_access_key"] ?? "" + guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else { return nil } + let sessionToken = settings["aws_session_token"] + return AWSCredentials( + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + sessionToken: sessionToken?.isEmpty == true ? nil : sessionToken + ) + } + + private static func environmentCredentials(profileName: String) throws -> AWSCredentials { + let environment = ProcessInfo.processInfo.environment + let accessKeyId = environment["AWS_ACCESS_KEY_ID"] ?? "" + let secretAccessKey = environment["AWS_SECRET_ACCESS_KEY"] ?? "" + guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else { + throw AWSAuthError.profileIncomplete(profileName) + } + let sessionToken = environment["AWS_SESSION_TOKEN"] + return AWSCredentials( + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + sessionToken: sessionToken?.isEmpty == true ? nil : sessionToken + ) } private static func runCredentialProcess(_ command: String, profileName: String) async throws -> AWSCredentials { + #if os(macOS) let arguments = tokenizeCommand(command) guard !arguments.isEmpty else { throw AWSAuthError.credentialProcessInvalid(profileName) @@ -98,8 +145,12 @@ enum AWSCredentialResolver { } return try parseCredentialProcessOutput(output, profileName: profileName) + #else + throw AWSAuthError.credentialProcessUnsupportedOnPlatform(profileName) + #endif } + #if os(macOS) private static func executeCredentialProcess(_ arguments: [String], profileName: String) throws -> Data { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") @@ -144,8 +195,9 @@ enum AWSCredentialResolver { environment["PATH"] = (searchPaths + inherited).joined(separator: ":") return environment } + #endif - static func tokenizeCommand(_ command: String) -> [String] { + public static func tokenizeCommand(_ command: String) -> [String] { var tokens: [String] = [] var current = "" var inQuotes = false @@ -180,16 +232,18 @@ enum AWSCredentialResolver { let accessKeyId: String let secretAccessKey: String let sessionToken: String? + let expiration: String? enum CodingKeys: String, CodingKey { case version = "Version" case accessKeyId = "AccessKeyId" case secretAccessKey = "SecretAccessKey" case sessionToken = "SessionToken" + case expiration = "Expiration" } } - static func parseCredentialProcessOutput(_ data: Data, profileName: String) throws -> AWSCredentials { + public static func parseCredentialProcessOutput(_ data: Data, profileName: String) throws -> AWSCredentials { guard let output = try? JSONDecoder().decode(CredentialProcessOutput.self, from: data) else { throw AWSAuthError.credentialProcessBadOutput(profileName) } @@ -202,16 +256,22 @@ enum AWSCredentialResolver { return AWSCredentials( accessKeyId: output.accessKeyId, secretAccessKey: output.secretAccessKey, - sessionToken: output.sessionToken?.isEmpty == true ? nil : output.sessionToken + sessionToken: output.sessionToken?.isEmpty == true ? nil : output.sessionToken, + expiration: output.expiration.flatMap(parseISO8601) ) } + static func parseISO8601(_ value: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return withFractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } + private static func resolveSSO(fields: [String: String]) async throws -> AWSCredentials { let profileName = fields["awsProfileName"].flatMap { $0.isEmpty ? nil : $0 } ?? "default" - let configPath = NSString("~/.aws/config").expandingTildeInPath let cacheDir = NSString("~/.aws/sso/cache").expandingTildeInPath - guard let configContent = try? String(contentsOfFile: configPath, encoding: .utf8) else { + guard let configContent = AWSConfigFile.readFile(AWSConfigFile.defaultConfigPath) else { throw AWSSSOError.configReadFailed } @@ -230,7 +290,8 @@ enum AWSCredentialResolver { return AWSCredentials( accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken + sessionToken: credentials.sessionToken, + expiration: credentials.expiration ) } } diff --git a/Plugins/TableProPluginKit/AWS/AWSCredentials.swift b/Plugins/TableProPluginKit/AWS/AWSCredentials.swift new file mode 100644 index 000000000..69953f58d --- /dev/null +++ b/Plugins/TableProPluginKit/AWS/AWSCredentials.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct AWSCredentials: Sendable, Equatable { + public let accessKeyId: String + public let secretAccessKey: String + public let sessionToken: String? + public let expiration: Date? + + public init( + accessKeyId: String, + secretAccessKey: String, + sessionToken: String?, + expiration: Date? = nil + ) { + self.accessKeyId = accessKeyId + self.secretAccessKey = secretAccessKey + self.sessionToken = sessionToken + self.expiration = expiration + } + + public func isExpired(asOf now: Date = Date(), safetyWindow: TimeInterval = 300) -> Bool { + guard let expiration else { return false } + return expiration.timeIntervalSince(now) <= safetyWindow + } +} diff --git a/TablePro/Core/Database/AWS/AWSSSO.swift b/Plugins/TableProPluginKit/AWS/AWSSSO.swift similarity index 89% rename from TablePro/Core/Database/AWS/AWSSSO.swift rename to Plugins/TableProPluginKit/AWS/AWSSSO.swift index 695b43cdc..ee9c7a555 100644 --- a/TablePro/Core/Database/AWS/AWSSSO.swift +++ b/Plugins/TableProPluginKit/AWS/AWSSSO.swift @@ -10,21 +10,30 @@ import CommonCrypto import Foundation -struct AWSSSOProfileSettings: Equatable, Sendable { - let accountId: String - let roleName: String - let startUrl: String - let region: String - let ssoSession: String? +public struct AWSSSOProfileSettings: Equatable, Sendable { + public let accountId: String + public let roleName: String + public let startUrl: String + public let region: String + public let ssoSession: String? + + public init(accountId: String, roleName: String, startUrl: String, region: String, ssoSession: String?) { + self.accountId = accountId + self.roleName = roleName + self.startUrl = startUrl + self.region = region + self.ssoSession = ssoSession + } } -struct AWSSSORoleCredentials: Equatable, Sendable { - let accessKeyId: String - let secretAccessKey: String - let sessionToken: String +public struct AWSSSORoleCredentials: Equatable, Sendable { + public let accessKeyId: String + public let secretAccessKey: String + public let sessionToken: String + public let expiration: Date } -enum AWSSSOError: Error, LocalizedError, Equatable { +public enum AWSSSOError: Error, LocalizedError, Equatable { case configReadFailed case profileNotFound(String) case profileMissingFields(profile: String) @@ -43,7 +52,7 @@ enum AWSSSOError: Error, LocalizedError, Equatable { case responseDecodeFailed(profile: String) case credentialsAlreadyExpired(profile: String) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .configReadFailed: return String(localized: "Cannot read ~/.aws/config.") @@ -114,8 +123,8 @@ enum AWSSSOError: Error, LocalizedError, Equatable { } } -enum AWSSSO { - static func parseIniSections(_ content: String) -> [String: [String: String]] { +public enum AWSSSO { + public static func parseIniSections(_ content: String) -> [String: [String: String]] { var sections: [String: [String: String]] = [:] var current = "" @@ -144,7 +153,7 @@ enum AWSSSO { return sections } - static func parseProfileSettings(configContent: String, profileName: String) throws -> AWSSSOProfileSettings { + public static func parseProfileSettings(configContent: String, profileName: String) throws -> AWSSSOProfileSettings { let sections = parseIniSections(configContent) let profileSection = profileName == "default" ? "default" : "profile \(profileName)" @@ -186,7 +195,7 @@ enum AWSSSO { ) } - static func readAccessToken( + public static func readAccessToken( cacheDirectory: String, settings: AWSSSOProfileSettings, profileName: String, @@ -222,7 +231,7 @@ enum AWSSSO { return token.accessToken } - static func fetchRoleCredentials( + public static func fetchRoleCredentials( accessToken: String, settings: AWSSSOProfileSettings, profileName: String, @@ -291,15 +300,16 @@ enum AWSSSO { return AWSSSORoleCredentials( accessKeyId: envelope.roleCredentials.accessKeyId, secretAccessKey: envelope.roleCredentials.secretAccessKey, - sessionToken: envelope.roleCredentials.sessionToken + sessionToken: envelope.roleCredentials.sessionToken, + expiration: expiry ) } - static func sha1Hex(_ data: Data) -> String { + public static func sha1Hex(_ data: Data) -> String { var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) data.withUnsafeBytes { ptr in _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &hash) } - return hash.hexEncoded + return hash.map { String(format: "%02x", $0) }.joined() } } diff --git a/Plugins/TableProPluginKit/AWS/AWSSSOLogin.swift b/Plugins/TableProPluginKit/AWS/AWSSSOLogin.swift new file mode 100644 index 000000000..1add79e1f --- /dev/null +++ b/Plugins/TableProPluginKit/AWS/AWSSSOLogin.swift @@ -0,0 +1,268 @@ +import Foundation + +public struct AWSSSODeviceAuthorization: Sendable, Equatable { + public let deviceCode: String + public let userCode: String + public let verificationUri: String + public let verificationUriComplete: String + public let interval: Int + public let expiresIn: Int +} + +public enum AWSSSOLoginError: Error, LocalizedError, Equatable { + case network(String) + case unexpectedResponse + case authorizationTimedOut + case accessDenied + case serverError(String) + + public var errorDescription: String? { + switch self { + case .network(let detail): + return String(format: String(localized: "Could not reach AWS SSO: %@"), detail) + case .unexpectedResponse: + return String(localized: "AWS SSO returned an unexpected response.") + case .authorizationTimedOut: + return String(localized: "The AWS SSO sign-in timed out before it was approved.") + case .accessDenied: + return String(localized: "The AWS SSO sign-in was denied.") + case .serverError(let detail): + return String(format: String(localized: "AWS SSO sign-in failed: %@"), detail) + } + } +} + +public enum AWSSSOLogin { + public struct ClientRegistration: Decodable, Sendable, Equatable { + public let clientId: String + public let clientSecret: String + } + + public struct TokenResponse: Decodable, Sendable, Equatable { + public let accessToken: String + public let expiresIn: Int + } + + public static func registerClient( + region: String, + clientName: String, + session: URLSession + ) async throws -> ClientRegistration { + let body: [String: Any] = [ + "clientName": clientName, + "clientType": "public", + "scopes": ["sso:account:access"] + ] + let data = try await post(path: "client/register", region: region, body: body, session: session) + guard let registration = try? JSONDecoder().decode(ClientRegistration.self, from: data) else { + throw AWSSSOLoginError.unexpectedResponse + } + return registration + } + + public static func startDeviceAuthorization( + region: String, + clientId: String, + clientSecret: String, + startUrl: String, + session: URLSession + ) async throws -> AWSSSODeviceAuthorization { + let body: [String: Any] = [ + "clientId": clientId, + "clientSecret": clientSecret, + "startUrl": startUrl + ] + let data = try await post(path: "device_authorization", region: region, body: body, session: session) + return try parseDeviceAuthorization(data) + } + + public static func parseDeviceAuthorization(_ data: Data) throws -> AWSSSODeviceAuthorization { + struct Response: Decodable { + let deviceCode: String + let userCode: String + let verificationUri: String + let verificationUriComplete: String + let expiresIn: Int + let interval: Int? + } + guard let response = try? JSONDecoder().decode(Response.self, from: data) else { + throw AWSSSOLoginError.unexpectedResponse + } + return AWSSSODeviceAuthorization( + deviceCode: response.deviceCode, + userCode: response.userCode, + verificationUri: response.verificationUri, + verificationUriComplete: response.verificationUriComplete, + interval: response.interval ?? 5, + expiresIn: response.expiresIn + ) + } + + public enum TokenPoll: Equatable { + case token(accessToken: String, expiresIn: Int) + case pending + case slowDown + case denied + case expired + case failed(String) + } + + public static func interpretTokenResponse(status: Int, data: Data) -> TokenPoll { + if status == 200 { + guard let token = try? JSONDecoder().decode(TokenResponse.self, from: data) else { + return .failed("invalid token response") + } + return .token(accessToken: token.accessToken, expiresIn: token.expiresIn) + } + struct OIDCError: Decodable { let error: String? } + let code = (try? JSONDecoder().decode(OIDCError.self, from: data))?.error ?? "unknown_error" + switch code { + case "authorization_pending": return .pending + case "slow_down": return .slowDown + case "access_denied": return .denied + case "expired_token": return .expired + default: return .failed(code) + } + } + + public static func tokenCacheContents( + accessToken: String, + expiresAt: Date, + region: String, + startUrl: String + ) throws -> Data { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let payload: [String: String] = [ + "accessToken": accessToken, + "expiresAt": formatter.string(from: expiresAt), + "region": region, + "startUrl": startUrl + ] + return try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + } + + public static func writeTokenCache( + cacheKey: String, + accessToken: String, + expiresAt: Date, + region: String, + startUrl: String, + cacheDirectory: String + ) throws { + try FileManager.default.createDirectory(atPath: cacheDirectory, withIntermediateDirectories: true) + let fileName = AWSSSO.sha1Hex(Data(cacheKey.utf8)) + ".json" + let path = (cacheDirectory as NSString).appendingPathComponent(fileName) + let contents = try tokenCacheContents( + accessToken: accessToken, expiresAt: expiresAt, region: region, startUrl: startUrl + ) + try contents.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + public static func login( + profileName: String, + configContents: String, + cacheDirectory: String, + openVerificationURL: @escaping @Sendable (URL) -> Void, + session: URLSession = .shared + ) async throws { + let settings = try AWSSSO.parseProfileSettings(configContent: configContents, profileName: profileName) + let registration = try await registerClient(region: settings.region, clientName: "TablePro", session: session) + let auth = try await startDeviceAuthorization( + region: settings.region, + clientId: registration.clientId, + clientSecret: registration.clientSecret, + startUrl: settings.startUrl, + session: session + ) + if let url = URL(string: auth.verificationUriComplete) { + openVerificationURL(url) + } + + let deadline = Date().addingTimeInterval(TimeInterval(auth.expiresIn)) + var interval = max(auth.interval, 1) + while Date() < deadline { + try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + let poll = try await pollToken( + region: settings.region, + clientId: registration.clientId, + clientSecret: registration.clientSecret, + deviceCode: auth.deviceCode, + session: session + ) + switch poll { + case .token(let accessToken, let expiresIn): + try writeTokenCache( + cacheKey: settings.ssoSession ?? settings.startUrl, + accessToken: accessToken, + expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)), + region: settings.region, + startUrl: settings.startUrl, + cacheDirectory: cacheDirectory + ) + return + case .pending: + continue + case .slowDown: + interval += 5 + case .denied: + throw AWSSSOLoginError.accessDenied + case .expired: + throw AWSSSOLoginError.authorizationTimedOut + case .failed(let detail): + throw AWSSSOLoginError.serverError(detail) + } + } + throw AWSSSOLoginError.authorizationTimedOut + } + + private static func pollToken( + region: String, + clientId: String, + clientSecret: String, + deviceCode: String, + session: URLSession + ) async throws -> TokenPoll { + let body: [String: Any] = [ + "clientId": clientId, + "clientSecret": clientSecret, + "grantType": "urn:ietf:params:oauth:grant-type:device_code", + "deviceCode": deviceCode + ] + guard let url = URL(string: "https://oidc.\(region).amazonaws.com/token") else { + return .failed("invalid token endpoint") + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + do { + let (data, response) = try await session.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + return interpretTokenResponse(status: status, data: data) + } catch { + throw AWSSSOLoginError.network(error.localizedDescription) + } + } + + private static func post( + path: String, + region: String, + body: [String: Any], + session: URLSession + ) async throws -> Data { + guard let url = URL(string: "https://oidc.\(region).amazonaws.com/\(path)") else { + throw AWSSSOLoginError.unexpectedResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + do { + let (data, _) = try await session.data(for: request) + return data + } catch { + throw AWSSSOLoginError.network(error.localizedDescription) + } + } +} diff --git a/Plugins/TableProPluginKit/AWS/AWSSTS.swift b/Plugins/TableProPluginKit/AWS/AWSSTS.swift new file mode 100644 index 000000000..8ef555bfa --- /dev/null +++ b/Plugins/TableProPluginKit/AWS/AWSSTS.swift @@ -0,0 +1,212 @@ +import Foundation + +public enum AWSSTS { + public static func assumeRole( + roleArn: String, + roleSessionName: String, + externalId: String?, + durationSeconds: Int?, + region: String, + baseCredentials: AWSCredentials, + session: URLSession, + now: Date = Date() + ) async throws -> AWSCredentials { + let service = "sts" + let host = "sts.\(region).amazonaws.com" + + var params: [(String, String)] = [ + ("Action", "AssumeRole"), + ("Version", "2011-06-15"), + ("RoleArn", roleArn), + ("RoleSessionName", roleSessionName) + ] + if let durationSeconds { + params.append(("DurationSeconds", String(durationSeconds))) + } + if let externalId, !externalId.isEmpty { + params.append(("ExternalId", externalId)) + } + + let body = params + .map { "\(AWSSigV4.uriEncode($0.0))=\(AWSSigV4.uriEncode($0.1))" } + .joined(separator: "&") + let bodyData = Data(body.utf8) + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + let amzDate = formatter.string(from: now) + formatter.dateFormat = "yyyyMMdd" + let dateStamp = formatter.string(from: now) + + let contentType = "application/x-www-form-urlencoded; charset=utf-8" + let payloadHash = AWSSigV4.sha256Hex(bodyData) + + var canonicalHeaders = "content-type:\(contentType)\nhost:\(host)\nx-amz-date:\(amzDate)\n" + var signedHeaders = "content-type;host;x-amz-date" + if let sessionToken = baseCredentials.sessionToken, !sessionToken.isEmpty { + canonicalHeaders += "x-amz-security-token:\(sessionToken)\n" + signedHeaders = "content-type;host;x-amz-date;x-amz-security-token" + } + + let canonicalRequest = [ + "POST", "/", "", canonicalHeaders, signedHeaders, payloadHash + ].joined(separator: "\n") + + let credentialScope = "\(dateStamp)/\(region)/\(service)/aws4_request" + let stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + AWSSigV4.sha256Hex(Data(canonicalRequest.utf8)) + ].joined(separator: "\n") + + let signingKey = AWSSigV4.deriveSigningKey( + secretKey: baseCredentials.secretAccessKey, + dateStamp: dateStamp, + region: region, + service: service + ) + let signature = AWSSigV4.hmacHex(key: signingKey, data: Data(stringToSign.utf8)) + let authorization = "AWS4-HMAC-SHA256 " + + "Credential=\(baseCredentials.accessKeyId)/\(credentialScope), " + + "SignedHeaders=\(signedHeaders), Signature=\(signature)" + + guard let url = URL(string: "https://\(host)/") else { + throw AWSAuthError.assumeRoleFailed(role: roleArn, message: "Invalid STS endpoint") + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = bodyData + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue(amzDate, forHTTPHeaderField: "X-Amz-Date") + request.setValue(authorization, forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let sessionToken = baseCredentials.sessionToken, !sessionToken.isEmpty { + request.setValue(sessionToken, forHTTPHeaderField: "X-Amz-Security-Token") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AWSAuthError.assumeRoleFailed(role: roleArn, message: error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw AWSAuthError.assumeRoleFailed(role: roleArn, message: "Unexpected STS response") + } + guard http.statusCode == 200 else { + throw AWSAuthError.assumeRoleFailed(role: roleArn, message: stsErrorMessage(data) ?? "HTTP \(http.statusCode)") + } + + return try parseAssumeRoleResponse(data, roleArn: roleArn) + } + + public static func parseAssumeRoleResponse(_ data: Data, roleArn: String) throws -> AWSCredentials { + let parser = CredentialsXMLParser() + guard parser.parse(data), + let accessKeyId = parser.accessKeyId, + let secretAccessKey = parser.secretAccessKey, + let sessionToken = parser.sessionToken + else { + throw AWSAuthError.assumeRoleFailed(role: roleArn, message: "Could not read credentials from the STS response") + } + let expiration = parser.expiration.flatMap(AWSCredentialResolver.parseISO8601) + return AWSCredentials( + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + sessionToken: sessionToken, + expiration: expiration + ) + } + + private static func stsErrorMessage(_ data: Data) -> String? { + let parser = ErrorXMLParser() + guard parser.parse(data) else { return nil } + switch (parser.code, parser.message) { + case let (code?, message?): + return "\(code): \(message)" + case let (code?, nil): + return code + case let (nil, message?): + return message + default: + return nil + } + } +} + +private final class CredentialsXMLParser: NSObject, XMLParserDelegate { + var accessKeyId: String? + var secretAccessKey: String? + var sessionToken: String? + var expiration: String? + + private var element = "" + private var buffer = "" + + func parse(_ data: Data) -> Bool { + let parser = XMLParser(data: data) + parser.delegate = self + return parser.parse() + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String]) { + element = elementName + buffer = "" + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + buffer += string + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName: String?) { + let value = buffer.trimmingCharacters(in: .whitespacesAndNewlines) + switch elementName { + case "AccessKeyId": accessKeyId = value + case "SecretAccessKey": secretAccessKey = value + case "SessionToken": sessionToken = value + case "Expiration": expiration = value + default: break + } + buffer = "" + } +} + +private final class ErrorXMLParser: NSObject, XMLParserDelegate { + var code: String? + var message: String? + + private var buffer = "" + + func parse(_ data: Data) -> Bool { + let parser = XMLParser(data: data) + parser.delegate = self + return parser.parse() + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String]) { + buffer = "" + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + buffer += string + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName: String?) { + let value = buffer.trimmingCharacters(in: .whitespacesAndNewlines) + switch elementName { + case "Code": code = value + case "Message": message = value + default: break + } + buffer = "" + } +} diff --git a/TablePro/Core/Database/AWS/AWSSigV4.swift b/Plugins/TableProPluginKit/AWS/AWSSigV4.swift similarity index 63% rename from TablePro/Core/Database/AWS/AWSSigV4.swift rename to Plugins/TableProPluginKit/AWS/AWSSigV4.swift index db96d0ff0..04a722405 100644 --- a/TablePro/Core/Database/AWS/AWSSigV4.swift +++ b/Plugins/TableProPluginKit/AWS/AWSSigV4.swift @@ -1,17 +1,8 @@ -// -// AWSSigV4.swift -// TablePro -// -// AWS Signature Version 4 primitives over CommonCrypto. Used to presign the -// RDS IAM connect URL. Mirrors the signing primitives in the DynamoDB driver, -// which lives in a separate plugin binary the host cannot link against. -// - import CommonCrypto import Foundation -enum AWSSigV4 { - static func hmac(key: Data, data: Data) -> Data { +public enum AWSSigV4 { + public static func hmac(key: Data, data: Data) -> Data { var result = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) key.withUnsafeBytes { keyPtr in data.withUnsafeBytes { dataPtr in @@ -26,19 +17,19 @@ enum AWSSigV4 { return Data(result) } - static func hmacHex(key: Data, data: Data) -> String { - hmac(key: key, data: data).hexEncoded + public static func hmacHex(key: Data, data: Data) -> String { + hmac(key: key, data: data).map { String(format: "%02x", $0) }.joined() } - static func sha256Hex(_ data: Data) -> String { + public static func sha256Hex(_ data: Data) -> String { var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) data.withUnsafeBytes { ptr in _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &hash) } - return hash.hexEncoded + return hash.map { String(format: "%02x", $0) }.joined() } - static func deriveSigningKey(secretKey: String, dateStamp: String, region: String, service: String) -> Data { + public static func deriveSigningKey(secretKey: String, dateStamp: String, region: String, service: String) -> Data { let kDate = hmac(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8)) let kRegion = hmac(key: kDate, data: Data(region.utf8)) let kService = hmac(key: kRegion, data: Data(service.utf8)) @@ -49,7 +40,7 @@ enum AWSSigV4 { charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" ) - static func uriEncode(_ value: String) -> String { + public static func uriEncode(_ value: String) -> String { value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value } } diff --git a/Plugins/TableProPluginKit/AWS/ElastiCacheAuthTokenGenerator.swift b/Plugins/TableProPluginKit/AWS/ElastiCacheAuthTokenGenerator.swift new file mode 100644 index 000000000..a42af6f9a --- /dev/null +++ b/Plugins/TableProPluginKit/AWS/ElastiCacheAuthTokenGenerator.swift @@ -0,0 +1,75 @@ +import Foundation + +public enum ElastiCacheAuthTokenGenerator { + private static let service = "elasticache" + private static let expirySeconds = 900 + + public static func generateToken( + replicationGroupId: String, + region: String, + userId: String, + credentials: AWSCredentials, + now: Date = Date() + ) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + let amzDate = formatter.string(from: now) + formatter.dateFormat = "yyyyMMdd" + let dateStamp = formatter.string(from: now) + + let credentialScope = "\(dateStamp)/\(region)/\(service)/aws4_request" + let credential = "\(credentials.accessKeyId)/\(credentialScope)" + + var params: [(String, String)] = [ + ("Action", "connect"), + ("User", userId), + ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"), + ("X-Amz-Credential", credential), + ("X-Amz-Date", amzDate), + ("X-Amz-Expires", String(expirySeconds)), + ("X-Amz-SignedHeaders", "host") + ] + if let sessionToken = credentials.sessionToken, !sessionToken.isEmpty { + params.append(("X-Amz-Security-Token", sessionToken)) + } + + let canonicalQuery = params + .map { (AWSSigV4.uriEncode($0.0), AWSSigV4.uriEncode($0.1)) } + .sorted { $0.0 < $1.0 } + .map { "\($0.0)=\($0.1)" } + .joined(separator: "&") + + let canonicalHeaders = "host:\(replicationGroupId)\n" + let signedHeaders = "host" + let payloadHash = AWSSigV4.sha256Hex(Data()) + + let canonicalRequest = [ + "GET", + "/", + canonicalQuery, + canonicalHeaders, + signedHeaders, + payloadHash + ].joined(separator: "\n") + + let stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + AWSSigV4.sha256Hex(Data(canonicalRequest.utf8)) + ].joined(separator: "\n") + + let signingKey = AWSSigV4.deriveSigningKey( + secretKey: credentials.secretAccessKey, + dateStamp: dateStamp, + region: region, + service: service + ) + let signature = AWSSigV4.hmacHex(key: signingKey, data: Data(stringToSign.utf8)) + + let url = "https://\(replicationGroupId)/?\(canonicalQuery)&X-Amz-Signature=\(signature)" + return String(url.dropFirst("https://".count)) + } +} diff --git a/Plugins/TableProPluginKit/AWS/KeyspacesSigV4.swift b/Plugins/TableProPluginKit/AWS/KeyspacesSigV4.swift new file mode 100644 index 000000000..7eff45afa --- /dev/null +++ b/Plugins/TableProPluginKit/AWS/KeyspacesSigV4.swift @@ -0,0 +1,72 @@ +import Foundation + +public enum KeyspacesSigV4 { + public static let initialResponse = "SigV4\u{0}\u{0}" + public static let nonceKey = "nonce=" + public static let nonceLength = 32 + private static let service = "cassandra" + private static let expirySeconds = 900 + + public static func nonce(fromChallenge challenge: Data) -> Data? { + let key = Data(nonceKey.utf8) + guard let range = challenge.range(of: key) else { return nil } + let start = range.upperBound + let end = start + nonceLength + guard end <= challenge.endIndex else { return nil } + return challenge.subdata(in: start.. String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + let amzDate = formatter.string(from: now) + formatter.dateFormat = "yyyyMMdd" + let dateStamp = formatter.string(from: now) + + let scope = "\(dateStamp)/\(region)/\(service)/aws4_request" + let credential = "\(credentials.accessKeyId)/\(scope)" + + let params: [(String, String)] = [ + ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"), + ("X-Amz-Credential", credential), + ("X-Amz-Date", amzDate), + ("X-Amz-Expires", String(expirySeconds)) + ] + let query = params + .map { (AWSSigV4.uriEncode($0.0), AWSSigV4.uriEncode($0.1)) } + .sorted { $0.0 < $1.0 } + .map { "\($0.0)=\($0.1)" } + .joined(separator: "&") + + let payloadHash = AWSSigV4.sha256Hex(nonce) + let canonicalRequest = "PUT\n/authenticate\n\(query)\nhost:\(service)\n\nhost\n\(payloadHash)" + + let stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + scope, + AWSSigV4.sha256Hex(Data(canonicalRequest.utf8)) + ].joined(separator: "\n") + + let signingKey = AWSSigV4.deriveSigningKey( + secretKey: credentials.secretAccessKey, + dateStamp: dateStamp, + region: region, + service: service + ) + let signature = AWSSigV4.hmacHex(key: signingKey, data: Data(stringToSign.utf8)) + + var response = "signature=\(signature),access_key=\(credentials.accessKeyId),amzdate=\(amzDate)" + if let sessionToken = credentials.sessionToken, !sessionToken.isEmpty { + response += ",session_token=\(sessionToken)" + } + return response + } +} diff --git a/Plugins/TableProPluginKit/ConnectionField.swift b/Plugins/TableProPluginKit/ConnectionField.swift index 6b2f67040..228a4d37d 100644 --- a/Plugins/TableProPluginKit/ConnectionField.swift +++ b/Plugins/TableProPluginKit/ConnectionField.swift @@ -7,6 +7,10 @@ public enum FieldSection: String, Codable, Sendable { case connection } +public enum DynamicFieldOptions: String, Codable, Sendable { + case awsProfiles +} + public struct FieldVisibilityRule: Codable, Sendable, Equatable { public let fieldId: String public let values: [String] @@ -92,6 +96,7 @@ public struct ConnectionField: Codable, Sendable { public let section: FieldSection public let hidesPassword: Bool public let visibleWhen: FieldVisibilityRule? + public var dynamicOptions: DynamicFieldOptions? /// Backward-compatible convenience: true when fieldType is .secure public var isSecure: Bool { @@ -122,6 +127,12 @@ public struct ConnectionField: Codable, Sendable { self.visibleWhen = visibleWhen } + public func withDynamicOptions(_ options: DynamicFieldOptions?) -> ConnectionField { + var copy = self + copy.dynamicOptions = options + return copy + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) @@ -133,9 +144,11 @@ public struct ConnectionField: Codable, Sendable { section = try container.decodeIfPresent(FieldSection.self, forKey: .section) ?? .advanced hidesPassword = try container.decodeIfPresent(Bool.self, forKey: .hidesPassword) ?? false visibleWhen = try container.decodeIfPresent(FieldVisibilityRule.self, forKey: .visibleWhen) + dynamicOptions = try container.decodeIfPresent(DynamicFieldOptions.self, forKey: .dynamicOptions) } private enum CodingKeys: String, CodingKey { case id, label, placeholder, isRequired, defaultValue, fieldType, section, hidesPassword, visibleWhen + case dynamicOptions } } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 3b3f398e9..9988d0646 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -65,7 +65,6 @@ 5ADDB00100000000000000A6 /* DynamoDBQueryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A6 /* DynamoDBQueryBuilder.swift */; }; 5ADDB00100000000000000A7 /* DynamoDBStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A7 /* DynamoDBStatementGenerator.swift */; }; 5ADDB00100000000000000A8 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5ADDB00100000000000000F0 /* DynamoDBSsoCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000F0 /* DynamoDBSsoCredentials.swift */; }; 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEA8B422F6808CA0040461A /* EtcdStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B402F6808CA0040461A /* EtcdStatementGenerator.swift */; }; 5AEA8B432F6808CA0040461A /* EtcdPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3D2F6808CA0040461A /* EtcdPlugin.swift */; }; @@ -331,7 +330,6 @@ 5ADDB00200000000000000A5 /* DynamoDBPluginDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBPluginDriver.swift; sourceTree = ""; }; 5ADDB00200000000000000A6 /* DynamoDBQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBQueryBuilder.swift; sourceTree = ""; }; 5ADDB00200000000000000A7 /* DynamoDBStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBStatementGenerator.swift; sourceTree = ""; }; - 5ADDB00200000000000000F0 /* DynamoDBSsoCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBSsoCredentials.swift; sourceTree = ""; }; 5ADDB00300000000000000A0 /* DynamoDBDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DynamoDBDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudflareD1DriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EtcdDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1070,7 +1068,6 @@ 5ADDB00200000000000000A5 /* DynamoDBPluginDriver.swift */, 5ADDB00200000000000000A6 /* DynamoDBQueryBuilder.swift */, 5ADDB00200000000000000A7 /* DynamoDBStatementGenerator.swift */, - 5ADDB00200000000000000F0 /* DynamoDBSsoCredentials.swift */, ); path = Plugins/DynamoDBDriverPlugin; sourceTree = ""; @@ -2230,7 +2227,6 @@ 5ADDB00100000000000000A5 /* DynamoDBPluginDriver.swift in Sources */, 5ADDB00100000000000000A6 /* DynamoDBQueryBuilder.swift in Sources */, 5ADDB00100000000000000A7 /* DynamoDBStatementGenerator.swift in Sources */, - 5ADDB00100000000000000F0 /* DynamoDBSsoCredentials.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TablePro/Core/Database/AWS/AWSCredentials.swift b/TablePro/Core/Database/AWS/AWSCredentials.swift deleted file mode 100644 index ea8a1779e..000000000 --- a/TablePro/Core/Database/AWS/AWSCredentials.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// AWSCredentials.swift -// TablePro -// - -import Foundation - -struct AWSCredentials: Sendable { - let accessKeyId: String - let secretAccessKey: String - let sessionToken: String? -} diff --git a/TablePro/Core/Database/AWS/AWSSSOLoginService.swift b/TablePro/Core/Database/AWS/AWSSSOLoginService.swift new file mode 100644 index 000000000..5f67ee4e8 --- /dev/null +++ b/TablePro/Core/Database/AWS/AWSSSOLoginService.swift @@ -0,0 +1,30 @@ +import AppKit +import Foundation +import TableProPluginKit + +enum AWSSSOLoginService { + static func isSSOExpired(_ error: Error) -> Bool { + guard let ssoError = error as? AWSSSOError else { return false } + switch ssoError { + case .tokenCacheNotFound, .tokenCacheMalformed, .tokenExpired, .sessionUnauthorized, .credentialsAlreadyExpired: + return true + default: + return false + } + } + + static func signIn(profileName: String) async throws { + guard let configContents = AWSConfigFile.readFile(AWSConfigFile.defaultConfigPath) else { + throw AWSSSOError.configReadFailed + } + let cacheDirectory = NSString("~/.aws/sso/cache").expandingTildeInPath + try await AWSSSOLogin.login( + profileName: profileName, + configContents: configContents, + cacheDirectory: cacheDirectory, + openVerificationURL: { url in + Task { @MainActor in NSWorkspace.shared.open(url) } + } + ) + } +} diff --git a/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift b/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift index 9a87c6085..1378bf4b7 100644 --- a/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift +++ b/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift @@ -8,6 +8,7 @@ // import Foundation +import TableProPluginKit enum RDSAuthTokenGenerator { private static let service = "rds-db" diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 3d1cbcc19..8542c56aa 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -450,11 +450,34 @@ enum DatabaseDriverFactory { fields: [String: String] ) async throws -> String { let source = fields["awsAuth"] ?? "accessKey" + let credentials = try await AWSCredentialResolver.resolve(source: source, fields: fields) + + if connection.type == .redis { + guard let region = fields["awsRegion"].flatMap({ $0.isEmpty ? nil : $0 }) else { + throw AWSAuthError.regionUnknown(host: connection.host) + } + guard connection.sslConfig.mode != .disabled else { + throw AWSAuthError.missingConfiguration( + String(localized: "ElastiCache IAM authentication requires TLS. Enable SSL in the connection's SSL settings.") + ) + } + guard let replicationGroupId = fields["awsReplicationGroupId"].flatMap({ $0.isEmpty ? nil : $0 }) else { + throw AWSAuthError.missingConfiguration( + String(localized: "Enter the ElastiCache cache name (replication group ID) to use IAM authentication.") + ) + } + return ElastiCacheAuthTokenGenerator.generateToken( + replicationGroupId: replicationGroupId, + region: region, + userId: connection.username, + credentials: credentials + ) + } + let explicitRegion = fields["awsRegion"].flatMap { $0.isEmpty ? nil : $0 } guard let region = explicitRegion ?? RDSEndpoint.region(forHost: connection.host) else { throw AWSAuthError.regionUnknown(host: connection.host) } - let credentials = try await AWSCredentialResolver.resolve(source: source, fields: fields) return RDSAuthTokenGenerator.generateToken( host: connection.host, port: connection.port, @@ -469,7 +492,7 @@ enum DatabaseDriverFactory { fields: [String: String], override: String? = nil ) async throws -> String { - if connection.usesAWSIAM { + if connection.usesAWSIAM, !connection.resolvesAWSIAMInDriver { return try await resolveIAMPassword(for: connection, fields: fields) } if let override { return override } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 6aa848f4a..764832255 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -131,7 +131,7 @@ extension PluginMetadataRegistry { placeholder: "default", section: .authentication, visibleWhen: FieldVisibilityRule(fieldId: "awsAuthMethod", values: ["profile", "sso"]) - ), + ).withDynamicOptions(.awsProfiles), ConnectionField( id: "awsRegion", label: String(localized: "AWS Region"), diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 98322ea91..a2b859112 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -421,58 +421,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { section: .advanced ) - let awsIAMFields: [ConnectionField] = [ - ConnectionField( - id: "awsAuth", - label: String(localized: "Authentication"), - defaultValue: "off", - fieldType: .dropdown(options: [ - .init(value: "off", label: String(localized: "Password")), - .init(value: "accessKey", label: String(localized: "AWS IAM (Access Key)")), - .init(value: "profile", label: String(localized: "AWS IAM (Profile)")), - .init(value: "sso", label: String(localized: "AWS IAM (SSO)")) - ]), - section: .authentication, - hidesPassword: true - ), - ConnectionField( - id: "awsRegion", - label: String(localized: "AWS Region"), - placeholder: "us-east-1", - section: .authentication, - visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey", "profile", "sso"]) - ), - ConnectionField( - id: "awsAccessKeyId", - label: String(localized: "Access Key ID"), - placeholder: "AKIA...", - section: .authentication, - visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) - ), - ConnectionField( - id: "awsSecretAccessKey", - label: String(localized: "Secret Access Key"), - placeholder: "wJalr...", - fieldType: .secure, - section: .authentication, - visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) - ), - ConnectionField( - id: "awsSessionToken", - label: String(localized: "Session Token"), - placeholder: String(localized: "Optional, for temporary credentials"), - fieldType: .secure, - section: .authentication, - visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) - ), - ConnectionField( - id: "awsProfileName", - label: String(localized: "Profile Name"), - placeholder: "default", - section: .authentication, - visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["profile", "sso"]) - ) - ] + let awsIAMFields = AWSAuthFields.standard() let defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ ("MySQL", PluginMetadataSnapshot( diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 8196a64bd..8fdf0794b 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -396,6 +396,10 @@ struct DatabaseConnection: Identifiable, Hashable { return value != "off" && !value.isEmpty } + var resolvesAWSIAMInDriver: Bool { + type == .cassandra || type == .scylladb + } + var preConnectScript: String? { get { additionalFields["preConnectScript"]?.nilIfEmpty } set { additionalFields["preConnectScript"] = newValue ?? "" } diff --git a/TablePro/Views/Connection/AWSProfileField.swift b/TablePro/Views/Connection/AWSProfileField.swift new file mode 100644 index 000000000..fd95ed74c --- /dev/null +++ b/TablePro/Views/Connection/AWSProfileField.swift @@ -0,0 +1,65 @@ +import AppKit +import SwiftUI +import TableProPluginKit + +struct AWSProfileField: NSViewRepresentable { + let placeholder: String + @Binding var value: String + + func makeCoordinator() -> Coordinator { + Coordinator(value: $value) + } + + func makeNSView(context: Context) -> NSComboBox { + let comboBox = NSComboBox() + comboBox.isEditable = true + comboBox.completes = true + comboBox.usesDataSource = false + comboBox.controlSize = .small + comboBox.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small)) + if !placeholder.isEmpty { + comboBox.placeholderString = placeholder + } + comboBox.addItems(withObjectValues: Self.discoveredProfiles()) + comboBox.stringValue = value + comboBox.delegate = context.coordinator + comboBox.setContentHuggingPriority(.defaultLow, for: .horizontal) + return comboBox + } + + func updateNSView(_ comboBox: NSComboBox, context: Context) { + context.coordinator.value = $value + if comboBox.stringValue != value { + comboBox.stringValue = value + } + } + + private static func discoveredProfiles() -> [String] { + AWSConfigFile.discoverProfiles( + configContents: AWSConfigFile.readFile(AWSConfigFile.defaultConfigPath), + credentialsContents: AWSConfigFile.readFile(AWSConfigFile.defaultCredentialsPath) + ) + } + + final class Coordinator: NSObject, NSComboBoxDelegate { + var value: Binding + + init(value: Binding) { + self.value = value + } + + func controlTextDidChange(_ notification: Notification) { + guard let comboBox = notification.object as? NSComboBox else { return } + value.wrappedValue = comboBox.stringValue + } + + func comboBoxSelectionDidChange(_ notification: Notification) { + guard let comboBox = notification.object as? NSComboBox else { return } + DispatchQueue.main.async { + if let selected = comboBox.objectValueOfSelectedItem as? String { + self.value.wrappedValue = selected + } + } + } + } +} diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index 54e7b819a..4a72e00d9 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -11,6 +11,16 @@ struct ConnectionFieldRow: View { @Binding var value: String var body: some View { + if field.dynamicOptions == .awsProfiles { + LabeledContent(field.label) { + AWSProfileField(placeholder: field.placeholder, value: $value) + } + } else { + defaultControl + } + } + + @ViewBuilder private var defaultControl: some View { switch field.fieldType { case .text: TextField( diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index c4313dd7e..30e15fbbb 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -536,6 +536,12 @@ final class ConnectionFormCoordinator { } } } catch { + let usesSSO = self?.auth.additionalFieldValues["awsAuth"] == "sso" + || self?.auth.additionalFieldValues["awsAuthMethod"] == "sso" + if usesSSO, AWSSSOLoginService.isSSOExpired(error) { + await self?.offerAWSSSOSignIn(testId: testConn.id, window: window) + return + } await MainActor.run { self?.cleanupTestSecrets(for: testConn.id) self?.isTesting = false @@ -559,6 +565,38 @@ final class ConnectionFormCoordinator { } } + private func offerAWSSSOSignIn(testId: UUID, window: NSWindow?) async { + cleanupTestSecrets(for: testId) + isTesting = false + testTask = nil + let profileName = auth.additionalFieldValues["awsProfileName"] + .flatMap { $0.isEmpty ? nil : $0 } ?? "default" + let confirmed = await AlertHelper.confirmCritical( + title: String(localized: "AWS SSO Sign-In Required"), + message: String( + format: String(localized: "The SSO session for profile \"%@\" has expired. Sign in with your browser?"), + profileName + ), + confirmButton: String(localized: "Sign In"), + window: window + ) + guard confirmed else { return } + do { + try await AWSSSOLoginService.signIn(profileName: profileName) + AlertHelper.showInfoSheet( + title: String(localized: "Signed In"), + message: String(localized: "AWS SSO sign-in finished. Test the connection again."), + window: window + ) + } catch { + AlertHelper.showErrorSheet( + title: String(localized: "AWS SSO Sign-In Failed"), + message: error.localizedDescription, + window: window + ) + } + } + private func persistTestSecrets( for testId: UUID, password: String, diff --git a/TableProTests/AWS/AWSConfigFileTests.swift b/TableProTests/AWS/AWSConfigFileTests.swift new file mode 100644 index 000000000..2c1ac82cf --- /dev/null +++ b/TableProTests/AWS/AWSConfigFileTests.swift @@ -0,0 +1,106 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("AWS config and credentials file resolution") +struct AWSConfigFileTests { + private let config = """ + [default] + region = us-east-1 + aws_access_key_id = CONFIG_DEFAULT_KEY + aws_secret_access_key = CONFIG_DEFAULT_SECRET + + [profile c9dev] + region = ap-south-1 + credential_process = /opt/bin/awscreds --profile c9dev + + [profile static-in-config] + aws_access_key_id = CONFIG_KEY + aws_secret_access_key = CONFIG_SECRET + + [sso-session my-sso] + sso_start_url = https://example.awsapps.com/start + sso_region = us-east-1 + """ + + private let credentials = """ + [work] + aws_access_key_id = CREDS_WORK_KEY + aws_secret_access_key = CREDS_WORK_SECRET + + [static-in-config] + aws_access_key_id = CREDS_OVERRIDE_KEY + aws_secret_access_key = CREDS_OVERRIDE_SECRET + """ + + @Test("A profile defined only in ~/.aws/config resolves (the reported bug)") + func profileOnlyInConfig() { + let settings = AWSConfigFile.mergedProfileSettings( + profileName: "c9dev", + configContents: config, + credentialsContents: credentials + ) + #expect(settings["credential_process"] == "/opt/bin/awscreds --profile c9dev") + #expect(settings["region"] == "ap-south-1") + } + + @Test("A profile defined only in ~/.aws/credentials resolves") + func profileOnlyInCredentials() { + let settings = AWSConfigFile.mergedProfileSettings( + profileName: "work", + configContents: config, + credentialsContents: credentials + ) + #expect(settings["aws_access_key_id"] == "CREDS_WORK_KEY") + #expect(settings["aws_secret_access_key"] == "CREDS_WORK_SECRET") + } + + @Test("When a profile is in both files, the credentials file wins") + func credentialsFileWins() { + let settings = AWSConfigFile.mergedProfileSettings( + profileName: "static-in-config", + configContents: config, + credentialsContents: credentials + ) + #expect(settings["aws_access_key_id"] == "CREDS_OVERRIDE_KEY") + } + + @Test("The default profile uses [default] in config and [default] in credentials") + func defaultProfile() { + let settings = AWSConfigFile.mergedProfileSettings( + profileName: "default", + configContents: config, + credentialsContents: credentials + ) + #expect(settings["aws_access_key_id"] == "CONFIG_DEFAULT_KEY") + #expect(settings["region"] == "us-east-1") + } + + @Test("A missing profile yields no settings") + func missingProfile() { + let settings = AWSConfigFile.mergedProfileSettings( + profileName: "nope", + configContents: config, + credentialsContents: credentials + ) + #expect(settings.isEmpty) + } + + @Test("Profile discovery merges both files, drops sso-session, and lists default first") + func discovery() { + let profiles = AWSConfigFile.discoverProfiles( + configContents: config, + credentialsContents: credentials + ) + #expect(profiles == ["default", "c9dev", "static-in-config", "work"]) + } + + @Test("Discovery deduplicates a profile present in both files") + func discoveryDedupes() { + let profiles = AWSConfigFile.discoverProfiles( + configContents: config, + credentialsContents: credentials + ) + #expect(profiles.filter { $0 == "static-in-config" }.count == 1) + } +} diff --git a/TableProTests/AWS/AWSSSOLoginTests.swift b/TableProTests/AWS/AWSSSOLoginTests.swift new file mode 100644 index 000000000..bf0ed6b68 --- /dev/null +++ b/TableProTests/AWS/AWSSSOLoginTests.swift @@ -0,0 +1,57 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("AWS SSO device login") +struct AWSSSOLoginTests { + @Test("Parses a device authorization response, defaulting the poll interval") + func parsesDeviceAuthorization() throws { + let json = """ + { + "deviceCode": "DC", + "userCode": "ABCD-EFGH", + "verificationUri": "https://device.sso.us-east-1.amazonaws.com/", + "verificationUriComplete": "https://device.sso.us-east-1.amazonaws.com/?user_code=ABCD-EFGH", + "expiresIn": 600 + } + """ + let auth = try AWSSSOLogin.parseDeviceAuthorization(Data(json.utf8)) + #expect(auth.deviceCode == "DC") + #expect(auth.userCode == "ABCD-EFGH") + #expect(auth.verificationUriComplete.contains("user_code=ABCD-EFGH")) + #expect(auth.expiresIn == 600) + #expect(auth.interval == 5) + } + + @Test("Token poll maps status and OIDC error codes to states") + func interpretsTokenPoll() { + let success = #"{"accessToken":"AT","expiresIn":3600,"tokenType":"Bearer"}"# + #expect(AWSSSOLogin.interpretTokenResponse(status: 200, data: Data(success.utf8)) + == .token(accessToken: "AT", expiresIn: 3_600)) + + func poll(_ code: String) -> AWSSSOLogin.TokenPoll { + AWSSSOLogin.interpretTokenResponse(status: 400, data: Data(#"{"error":"\#(code)"}"#.utf8)) + } + #expect(poll("authorization_pending") == .pending) + #expect(poll("slow_down") == .slowDown) + #expect(poll("access_denied") == .denied) + #expect(poll("expired_token") == .expired) + #expect(poll("invalid_grant") == .failed("invalid_grant")) + } + + @Test("Token cache contents match the AWS CLI shape") + func tokenCacheContents() throws { + let expiresAt = try #require(ISO8601DateFormatter().date(from: "2026-06-03T12:00:00Z")) + let data = try AWSSSOLogin.tokenCacheContents( + accessToken: "AT", + expiresAt: expiresAt, + region: "us-east-1", + startUrl: "https://example.awsapps.com/start" + ) + let object = try JSONSerialization.jsonObject(with: data) as? [String: String] + #expect(object?["accessToken"] == "AT") + #expect(object?["expiresAt"] == "2026-06-03T12:00:00Z") + #expect(object?["region"] == "us-east-1") + #expect(object?["startUrl"] == "https://example.awsapps.com/start") + } +} diff --git a/TableProTests/Plugins/DynamoDBSsoCredentialsTests.swift b/TableProTests/AWS/AWSSSOResolutionTests.swift similarity index 70% rename from TableProTests/Plugins/DynamoDBSsoCredentialsTests.swift rename to TableProTests/AWS/AWSSSOResolutionTests.swift index cff78e580..5feb129cc 100644 --- a/TableProTests/Plugins/DynamoDBSsoCredentialsTests.swift +++ b/TableProTests/AWS/AWSSSOResolutionTests.swift @@ -1,11 +1,5 @@ -// -// DynamoDBSsoCredentialsTests.swift -// TableProTests -// -// Tests for DynamoDBSso helpers (compiled via symlink from DynamoDBDriverPlugin). -// - import Foundation +import TableProPluginKit import Testing private let modernConfig = """ @@ -32,8 +26,8 @@ sso_account_id = 222222222222 sso_role_name = LegacyRole """ -@Suite("DynamoDBSso - INI parsing") -struct DynamoDBSsoIniTests { +@Suite("AWSSSO - INI parsing") +struct AWSSSOIniParsingTests { @Test("comment lines and empty lines are skipped") func skipsCommentsAndEmptyLines() { let content = """ @@ -43,31 +37,31 @@ struct DynamoDBSsoIniTests { [default] region = us-east-1 """ - let sections = DynamoDBSso.parseIniSections(content) + let sections = AWSSSO.parseIniSections(content) #expect(sections["default"]?["region"] == "us-east-1") #expect(sections.count == 1) } @Test("section + key/value parsed; URL with # in value preserved") func preservesHashInValues() { - let sections = DynamoDBSso.parseIniSections(modernConfig) + let sections = AWSSSO.parseIniSections(modernConfig) #expect(sections["sso-session my-sso"]?["sso_start_url"] == "https://example.awsapps.com/start#/") } @Test("orphan key before any section is dropped") func dropsOrphanKey() { let content = "rogue = value\n[default]\nregion = us-east-1\n" - let sections = DynamoDBSso.parseIniSections(content) + let sections = AWSSSO.parseIniSections(content) #expect(sections["default"]?["region"] == "us-east-1") #expect(sections.keys.contains("rogue") == false) } } -@Suite("DynamoDBSso - parseProfileSettings") -struct DynamoDBSsoProfileTests { +@Suite("AWSSSO - parseProfileSettings") +struct AWSSSOProfileSettingsTests { @Test("modern profile resolves all fields from sso-session block") func resolvesModernProfile() throws { - let s = try DynamoDBSso.parseProfileSettings(configContent: modernConfig, profileName: "my-profile") + let s = try AWSSSO.parseProfileSettings(configContent: modernConfig, profileName: "my-profile") #expect(s.accountId == "111111111111") #expect(s.roleName == "AWSAdministratorAccess") #expect(s.startUrl == "https://example.awsapps.com/start#/") @@ -77,7 +71,7 @@ struct DynamoDBSsoProfileTests { @Test("legacy profile resolves fields from profile block") func resolvesLegacyProfile() throws { - let s = try DynamoDBSso.parseProfileSettings(configContent: modernConfig, profileName: "legacy-profile") + let s = try AWSSSO.parseProfileSettings(configContent: modernConfig, profileName: "legacy-profile") #expect(s.startUrl == "https://legacy.awsapps.com/start") #expect(s.region == "us-east-1") #expect(s.ssoSession == nil) @@ -85,16 +79,16 @@ struct DynamoDBSsoProfileTests { @Test("missing profile throws profileNotFound") func throwsOnMissingProfile() { - #expect(throws: SsoCredentialError.profileNotFound("ghost")) { - _ = try DynamoDBSso.parseProfileSettings(configContent: modernConfig, profileName: "ghost") + #expect(throws: AWSSSOError.profileNotFound("ghost")) { + _ = try AWSSSO.parseProfileSettings(configContent: modernConfig, profileName: "ghost") } } @Test("profile missing account/role throws profileMissingFields") func throwsOnIncompleteProfile() { let content = "[profile partial]\nsso_account_id = 1\n" - #expect(throws: SsoCredentialError.profileMissingFields(profile: "partial")) { - _ = try DynamoDBSso.parseProfileSettings(configContent: content, profileName: "partial") + #expect(throws: AWSSSOError.profileMissingFields(profile: "partial")) { + _ = try AWSSSO.parseProfileSettings(configContent: content, profileName: "partial") } } @@ -106,8 +100,8 @@ struct DynamoDBSsoProfileTests { sso_account_id = 1 sso_role_name = R """ - #expect(throws: SsoCredentialError.sessionNotFound(profile: "orphan", session: "does-not-exist")) { - _ = try DynamoDBSso.parseProfileSettings(configContent: content, profileName: "orphan") + #expect(throws: AWSSSOError.sessionNotFound(profile: "orphan", session: "does-not-exist")) { + _ = try AWSSSO.parseProfileSettings(configContent: content, profileName: "orphan") } } @@ -118,26 +112,26 @@ struct DynamoDBSsoProfileTests { sso_account_id = 1 sso_role_name = R """ - #expect(throws: SsoCredentialError.profileMissingUrlOrRegion("bare")) { - _ = try DynamoDBSso.parseProfileSettings(configContent: content, profileName: "bare") + #expect(throws: AWSSSOError.profileMissingUrlOrRegion("bare")) { + _ = try AWSSSO.parseProfileSettings(configContent: content, profileName: "bare") } } } -@Suite("DynamoDBSso - readAccessToken") -struct DynamoDBSsoTokenTests { +@Suite("AWSSSO - readAccessToken") +struct AWSSSOTokenCacheTests { private func makeCacheDirectory() throws -> String { - let dir = NSTemporaryDirectory() + "DynamoDBSsoTokenTests_\(UUID().uuidString)/" + let dir = NSTemporaryDirectory() + "AWSSSOTokenCacheTests_\(UUID().uuidString)/" try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) return dir } private func writeTokenFile(at directory: String, key: String, contents: String) throws { - let path = (directory as NSString).appendingPathComponent(DynamoDBSso.sha1Hex(Data(key.utf8)) + ".json") + let path = (directory as NSString).appendingPathComponent(AWSSSO.sha1Hex(Data(key.utf8)) + ".json") try contents.write(toFile: path, atomically: true, encoding: .utf8) } - private let modernSettings = SsoProfileSettings( + private let modernSettings = AWSSSOProfileSettings( accountId: "111111111111", roleName: "AWSAdministratorAccess", startUrl: "https://example.awsapps.com/start#/", @@ -155,7 +149,7 @@ struct DynamoDBSsoTokenTests { key: "my-sso", contents: #"{"accessToken":"OIDC_TOKEN","expiresAt":"\#(future)"}"# ) - let token = try DynamoDBSso.readAccessToken( + let token = try AWSSSO.readAccessToken( cacheDirectory: dir, settings: modernSettings, profileName: "my-profile" ) #expect(token == "OIDC_TOKEN") @@ -170,8 +164,8 @@ struct DynamoDBSsoTokenTests { at: dir, key: "my-sso", contents: #"{"accessToken":"OLD","expiresAt":"\#(past)"}"# ) - #expect(throws: SsoCredentialError.tokenExpired(profile: "my-profile")) { - _ = try DynamoDBSso.readAccessToken( + #expect(throws: AWSSSOError.tokenExpired(profile: "my-profile")) { + _ = try AWSSSO.readAccessToken( cacheDirectory: dir, settings: modernSettings, profileName: "my-profile" ) } @@ -181,8 +175,8 @@ struct DynamoDBSsoTokenTests { func throwsOnMissingFile() throws { let dir = try makeCacheDirectory() defer { try? FileManager.default.removeItem(atPath: dir) } - #expect(throws: SsoCredentialError.tokenCacheNotFound(profile: "my-profile")) { - _ = try DynamoDBSso.readAccessToken( + #expect(throws: AWSSSOError.tokenCacheNotFound(profile: "my-profile")) { + _ = try AWSSSO.readAccessToken( cacheDirectory: dir, settings: modernSettings, profileName: "my-profile" ) } @@ -193,8 +187,8 @@ struct DynamoDBSsoTokenTests { let dir = try makeCacheDirectory() defer { try? FileManager.default.removeItem(atPath: dir) } try writeTokenFile(at: dir, key: "my-sso", contents: "{not json") - #expect(throws: SsoCredentialError.tokenCacheMalformed(profile: "my-profile")) { - _ = try DynamoDBSso.readAccessToken( + #expect(throws: AWSSSOError.tokenCacheMalformed(profile: "my-profile")) { + _ = try AWSSSO.readAccessToken( cacheDirectory: dir, settings: modernSettings, profileName: "my-profile" ) } @@ -205,7 +199,7 @@ struct DynamoDBSsoTokenTests { let dir = try makeCacheDirectory() defer { try? FileManager.default.removeItem(atPath: dir) } let future = ISO8601DateFormatter().string(from: Date().addingTimeInterval(3_600)) - let legacy = SsoProfileSettings( + let legacy = AWSSSOProfileSettings( accountId: "2", roleName: "R", startUrl: "https://legacy.example/start", @@ -216,14 +210,14 @@ struct DynamoDBSsoTokenTests { at: dir, key: legacy.startUrl, contents: #"{"accessToken":"LEGACY","expiresAt":"\#(future)"}"# ) - let token = try DynamoDBSso.readAccessToken( + let token = try AWSSSO.readAccessToken( cacheDirectory: dir, settings: legacy, profileName: "legacy-profile" ) #expect(token == "LEGACY") } } -private final class SsoStubProtocol: URLProtocol, @unchecked Sendable { +private final class AWSSSOStubProtocol: URLProtocol, @unchecked Sendable { nonisolated(unsafe) static var status: Int = 200 nonisolated(unsafe) static var body = Data() nonisolated(unsafe) static var captured: URLRequest? @@ -259,14 +253,14 @@ private final class SsoStubProtocol: URLProtocol, @unchecked Sendable { static func makeSession() -> URLSession { let cfg = URLSessionConfiguration.ephemeral - cfg.protocolClasses = [SsoStubProtocol.self] + cfg.protocolClasses = [AWSSSOStubProtocol.self] return URLSession(configuration: cfg) } } -@Suite("DynamoDBSso - fetchRoleCredentials") -struct DynamoDBSsoFetchTests { - private let settings = SsoProfileSettings( +@Suite("AWSSSO - fetchRoleCredentials") +struct AWSSSOFetchTests { + private let settings = AWSSSOProfileSettings( accountId: "111111111111", roleName: "AWSAdministratorAccess", startUrl: "https://example.awsapps.com/start#/", @@ -276,18 +270,18 @@ struct DynamoDBSsoFetchTests { @Test("200 response: URL host/path/query/header match AWS spec and credentials decode") func happyPath() async throws { - SsoStubProtocol.reset() + AWSSSOStubProtocol.reset() let exp = Int64(Date().addingTimeInterval(3_600).timeIntervalSince1970 * 1_000) - SsoStubProtocol.body = Data(#""" + AWSSSOStubProtocol.body = Data(#""" {"roleCredentials":{"accessKeyId":"AK","secretAccessKey":"SK","sessionToken":"ST","expiration":\#(exp)}} """#.utf8) - let creds = try await DynamoDBSso.fetchRoleCredentials( + let creds = try await AWSSSO.fetchRoleCredentials( accessToken: "BEARER", settings: settings, profileName: "p", - session: SsoStubProtocol.makeSession() + session: AWSSSOStubProtocol.makeSession() ) - let req = try #require(SsoStubProtocol.captured) + let req = try #require(AWSSSOStubProtocol.captured) let url = try #require(req.url) #expect(req.httpMethod == "GET") #expect(url.host == "portal.sso.eu-west-1.amazonaws.com") @@ -299,81 +293,82 @@ struct DynamoDBSsoFetchTests { #expect(creds.accessKeyId == "AK") #expect(creds.secretAccessKey == "SK") #expect(creds.sessionToken == "ST") + #expect(creds.expiration > Date()) } @Test("401 maps to sessionUnauthorized") func unauthorized() async throws { - SsoStubProtocol.reset() - SsoStubProtocol.status = 401 - await #expect(throws: SsoCredentialError.sessionUnauthorized(profile: "p")) { - _ = try await DynamoDBSso.fetchRoleCredentials( + AWSSSOStubProtocol.reset() + AWSSSOStubProtocol.status = 401 + await #expect(throws: AWSSSOError.sessionUnauthorized(profile: "p")) { + _ = try await AWSSSO.fetchRoleCredentials( accessToken: "T", settings: settings, profileName: "p", - session: SsoStubProtocol.makeSession() + session: AWSSSOStubProtocol.makeSession() ) } } @Test("403 maps to roleNotAccessible carrying role and account") func forbidden() async throws { - SsoStubProtocol.reset() - SsoStubProtocol.status = 403 + AWSSSOStubProtocol.reset() + AWSSSOStubProtocol.status = 403 await #expect( - throws: SsoCredentialError.roleNotAccessible(role: "AWSAdministratorAccess", account: "111111111111") + throws: AWSSSOError.roleNotAccessible(role: "AWSAdministratorAccess", account: "111111111111") ) { - _ = try await DynamoDBSso.fetchRoleCredentials( + _ = try await AWSSSO.fetchRoleCredentials( accessToken: "T", settings: settings, profileName: "p", - session: SsoStubProtocol.makeSession() + session: AWSSSOStubProtocol.makeSession() ) } } @Test("5xx maps to portalError with status code") func serverError() async throws { - SsoStubProtocol.reset() - SsoStubProtocol.status = 503 - await #expect(throws: SsoCredentialError.portalError(profile: "p", status: 503)) { - _ = try await DynamoDBSso.fetchRoleCredentials( + AWSSSOStubProtocol.reset() + AWSSSOStubProtocol.status = 503 + await #expect(throws: AWSSSOError.portalError(profile: "p", status: 503)) { + _ = try await AWSSSO.fetchRoleCredentials( accessToken: "T", settings: settings, profileName: "p", - session: SsoStubProtocol.makeSession() + session: AWSSSOStubProtocol.makeSession() ) } } @Test("network failure maps to networkFailure") func networkFailure() async throws { - SsoStubProtocol.reset() - SsoStubProtocol.simulateNetworkError = true - await #expect(throws: SsoCredentialError.self) { - _ = try await DynamoDBSso.fetchRoleCredentials( + AWSSSOStubProtocol.reset() + AWSSSOStubProtocol.simulateNetworkError = true + await #expect(throws: AWSSSOError.self) { + _ = try await AWSSSO.fetchRoleCredentials( accessToken: "T", settings: settings, profileName: "p", - session: SsoStubProtocol.makeSession() + session: AWSSSOStubProtocol.makeSession() ) } } @Test("200 with credentials whose expiration is past throws credentialsAlreadyExpired") func credentialsAlreadyExpired() async throws { - SsoStubProtocol.reset() + AWSSSOStubProtocol.reset() let pastMs = Int64(Date().addingTimeInterval(-60).timeIntervalSince1970 * 1_000) - SsoStubProtocol.body = Data(#""" + AWSSSOStubProtocol.body = Data(#""" {"roleCredentials":{"accessKeyId":"AK","secretAccessKey":"SK","sessionToken":"ST","expiration":\#(pastMs)}} """#.utf8) - await #expect(throws: SsoCredentialError.credentialsAlreadyExpired(profile: "p")) { - _ = try await DynamoDBSso.fetchRoleCredentials( + await #expect(throws: AWSSSOError.credentialsAlreadyExpired(profile: "p")) { + _ = try await AWSSSO.fetchRoleCredentials( accessToken: "T", settings: settings, profileName: "p", - session: SsoStubProtocol.makeSession() + session: AWSSSOStubProtocol.makeSession() ) } } @Test("200 with malformed JSON throws responseDecodeFailed") func malformedResponse() async throws { - SsoStubProtocol.reset() - SsoStubProtocol.body = Data("not json".utf8) - await #expect(throws: SsoCredentialError.responseDecodeFailed(profile: "p")) { - _ = try await DynamoDBSso.fetchRoleCredentials( + AWSSSOStubProtocol.reset() + AWSSSOStubProtocol.body = Data("not json".utf8) + await #expect(throws: AWSSSOError.responseDecodeFailed(profile: "p")) { + _ = try await AWSSSO.fetchRoleCredentials( accessToken: "T", settings: settings, profileName: "p", - session: SsoStubProtocol.makeSession() + session: AWSSSOStubProtocol.makeSession() ) } } diff --git a/TableProTests/AWS/AWSSTSTests.swift b/TableProTests/AWS/AWSSTSTests.swift new file mode 100644 index 000000000..eec01cd81 --- /dev/null +++ b/TableProTests/AWS/AWSSTSTests.swift @@ -0,0 +1,41 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("AWS STS AssumeRole response parsing") +struct AWSSTSTests { + private let validResponse = """ + + + + arn:aws:sts::123456789012:assumed-role/demo/tablepro + ARO123:tablepro + + + ASIAEXAMPLE + secretexample + tokenexample + 2026-06-03T12:00:00Z + + + + """ + + @Test("Parses credentials and expiration from a valid AssumeRole response") + func parsesValidResponse() throws { + let creds = try AWSSTS.parseAssumeRoleResponse(Data(validResponse.utf8), roleArn: "arn:aws:iam::123456789012:role/demo") + #expect(creds.accessKeyId == "ASIAEXAMPLE") + #expect(creds.secretAccessKey == "secretexample") + #expect(creds.sessionToken == "tokenexample") + let expected = ISO8601DateFormatter().date(from: "2026-06-03T12:00:00Z") + #expect(creds.expiration == expected) + } + + @Test("A response without credentials throws assumeRoleFailed") + func throwsOnMissingCredentials() { + let body = "" + #expect(throws: AWSAuthError.self) { + _ = try AWSSTS.parseAssumeRoleResponse(Data(body.utf8), roleArn: "arn:aws:iam::1:role/x") + } + } +} diff --git a/TableProTests/AWS/ElastiCacheAuthTokenTests.swift b/TableProTests/AWS/ElastiCacheAuthTokenTests.swift new file mode 100644 index 000000000..ff50c4ead --- /dev/null +++ b/TableProTests/AWS/ElastiCacheAuthTokenTests.swift @@ -0,0 +1,54 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("ElastiCache IAM auth token") +struct ElastiCacheAuthTokenTests { + private let credentials = AWSCredentials( + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + sessionToken: nil + ) + private let fixedDate = Date(timeIntervalSince1970: 1_440_938_160) + + private func makeToken(sessionToken: String? = nil) -> String { + ElastiCacheAuthTokenGenerator.generateToken( + replicationGroupId: "my-cache", + region: "us-east-1", + userId: "iam_user", + credentials: AWSCredentials( + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: sessionToken + ), + now: fixedDate + ) + } + + @Test("Token has the documented shape and no scheme") + func tokenShape() { + let token = makeToken() + #expect(!token.hasPrefix("https://")) + #expect(token.hasPrefix("my-cache/?")) + #expect(token.contains("Action=connect")) + #expect(token.contains("User=iam_user")) + #expect(token.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256")) + #expect(token.contains("X-Amz-Expires=900")) + #expect(token.contains("X-Amz-Credential=AKIDEXAMPLE%2F")) + #expect(token.contains("%2Felasticache%2Faws4_request")) + #expect(token.contains("X-Amz-Signature=")) + } + + @Test("Same inputs produce the same token") + func deterministic() { + let first = makeToken() + let second = makeToken() + #expect(first == second) + } + + @Test("Session token is included only for temporary credentials") + func sessionToken() { + #expect(!makeToken().contains("X-Amz-Security-Token")) + #expect(makeToken(sessionToken: "FQoGZXIvYXdzEXAMPLE").contains("X-Amz-Security-Token=FQoGZXIvYXdzEXAMPLE")) + } +} diff --git a/TableProTests/AWS/KeyspacesSigV4Tests.swift b/TableProTests/AWS/KeyspacesSigV4Tests.swift new file mode 100644 index 000000000..a03d29d64 --- /dev/null +++ b/TableProTests/AWS/KeyspacesSigV4Tests.swift @@ -0,0 +1,74 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("AWS Keyspaces SigV4 authentication") +struct KeyspacesSigV4Tests { + private let credentials = AWSCredentials( + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + sessionToken: nil + ) + private let fixedDate = Date(timeIntervalSince1970: 1_440_938_160) + + @Test("The initial SASL response is SigV4 followed by two null bytes") + func initialResponse() { + #expect(Array(KeyspacesSigV4.initialResponse.utf8) == [0x53, 0x69, 0x67, 0x56, 0x34, 0x00, 0x00]) + } + + @Test("Extracts the 32-byte nonce that follows the nonce= marker") + func nonceExtraction() { + let nonceBytes = Data((0..<32).map { UInt8($0) }) + var challenge = Data("nonce=".utf8) + challenge.append(nonceBytes) + challenge.append(Data(",foo=bar".utf8)) + #expect(KeyspacesSigV4.nonce(fromChallenge: challenge) == nonceBytes) + } + + @Test("Returns nil when the challenge has no nonce") + func nonceMissing() { + #expect(KeyspacesSigV4.nonce(fromChallenge: Data("hello".utf8)) == nil) + } + + @Test("Returns nil when the nonce is shorter than 32 bytes") + func nonceTruncated() { + let challenge = Data("nonce=".utf8) + Data((0..<10).map { UInt8($0) }) + #expect(KeyspacesSigV4.nonce(fromChallenge: challenge) == nil) + } + + @Test("The auth response carries signature, access key, and amzdate") + func authResponseShape() { + let nonce = Data((0..<32).map { UInt8($0) }) + let response = KeyspacesSigV4.authResponse( + nonce: nonce, credentials: credentials, region: "us-east-1", now: fixedDate + ) + let fields = Dictionary(uniqueKeysWithValues: response.split(separator: ",").compactMap { pair -> (String, String)? in + let parts = pair.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { return nil } + return (String(parts[0]), String(parts[1])) + }) + #expect(fields["access_key"] == "AKIDEXAMPLE") + #expect(fields["amzdate"] == "20150830T123600Z") + #expect(fields["signature"]?.count == 64) + #expect(fields["session_token"] == nil) + } + + @Test("A session token is included for temporary credentials") + func includesSessionToken() { + let temporary = AWSCredentials( + accessKeyId: "ASIA", secretAccessKey: "secret", sessionToken: "TEMP_TOKEN" + ) + let response = KeyspacesSigV4.authResponse( + nonce: Data(repeating: 7, count: 32), credentials: temporary, region: "eu-west-1", now: fixedDate + ) + #expect(response.contains("session_token=TEMP_TOKEN")) + } + + @Test("Same inputs produce the same signature") + func deterministic() { + let nonce = Data(repeating: 9, count: 32) + let first = KeyspacesSigV4.authResponse(nonce: nonce, credentials: credentials, region: "us-east-1", now: fixedDate) + let second = KeyspacesSigV4.authResponse(nonce: nonce, credentials: credentials, region: "us-east-1", now: fixedDate) + #expect(first == second) + } +} diff --git a/TableProTests/Core/Plugins/ConnectionFieldTests.swift b/TableProTests/Core/Plugins/ConnectionFieldTests.swift index 03f23fb1b..d89864145 100644 --- a/TableProTests/Core/Plugins/ConnectionFieldTests.swift +++ b/TableProTests/Core/Plugins/ConnectionFieldTests.swift @@ -4,7 +4,6 @@ import Testing @Suite("ConnectionField") struct ConnectionFieldTests { - @Test("Default values: placeholder, isRequired, defaultValue, fieldType") func defaultValues() { let field = ConnectionField(id: "host", label: "Host") @@ -265,4 +264,24 @@ struct ConnectionFieldTests { #expect(decoded.defaultValue == "0") #expect(decoded.fieldType == .stepper(range: range)) } + + @Test("dynamicOptions defaults to nil, round-trips, and is omitted when nil") + func codableDynamicOptions() throws { + let plain = ConnectionField(id: "host", label: "Host") + #expect(plain.dynamicOptions == nil) + + let plainJson = try #require(String(data: try JSONEncoder().encode(plain), encoding: .utf8)) + #expect(plainJson.contains("dynamicOptions") == false) + + let field = ConnectionField( + id: "awsProfileName", + label: "Profile Name", + section: .authentication + ).withDynamicOptions(.awsProfiles) + let decoded = try JSONDecoder().decode( + ConnectionField.self, + from: try JSONEncoder().encode(field) + ) + #expect(decoded.dynamicOptions == .awsProfiles) + } } diff --git a/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift b/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift index 37c81be2e..d51fd68a8 100644 --- a/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift +++ b/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift @@ -11,7 +11,6 @@ import Testing @Suite("DatabaseConnection.additionalFields") struct DatabaseConnectionAdditionalFieldsTests { - // MARK: - Defaults @Test("mongoAuthSource defaults to nil") @@ -275,4 +274,28 @@ struct DatabaseConnectionAdditionalFieldsTests { #expect(decoded.oracleServiceName == nil) #expect(decoded.redisDatabase == nil) } + + @Test("usesAWSIAM reflects the awsAuth field") + func usesAWSIAMReflectsField() { + func connection(_ awsAuth: String?) -> DatabaseConnection { + DatabaseConnection(name: "T", type: .mysql, additionalFields: awsAuth.map { ["awsAuth": $0] }) + } + #expect(connection(nil).usesAWSIAM == false) + #expect(connection("off").usesAWSIAM == false) + #expect(connection("").usesAWSIAM == false) + #expect(connection("accessKey").usesAWSIAM == true) + #expect(connection("profile").usesAWSIAM == true) + } + + @Test("Cassandra and ScyllaDB resolve AWS IAM in the driver, not via a token password") + func resolvesAWSIAMInDriverByType() { + func connection(_ type: DatabaseType) -> DatabaseConnection { + DatabaseConnection(name: "T", type: type, additionalFields: ["awsAuth": "accessKey"]) + } + #expect(connection(.cassandra).resolvesAWSIAMInDriver == true) + #expect(connection(.scylladb).resolvesAWSIAMInDriver == true) + #expect(connection(.mysql).resolvesAWSIAMInDriver == false) + #expect(connection(.postgresql).resolvesAWSIAMInDriver == false) + #expect(connection(.redis).resolvesAWSIAMInDriver == false) + } } diff --git a/TableProTests/PluginTestSources/DynamoDBSsoCredentials.swift b/TableProTests/PluginTestSources/DynamoDBSsoCredentials.swift deleted file mode 120000 index 922a274ac..000000000 --- a/TableProTests/PluginTestSources/DynamoDBSsoCredentials.swift +++ /dev/null @@ -1 +0,0 @@ -../../Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift \ No newline at end of file diff --git a/docs/databases/cassandra.mdx b/docs/databases/cassandra.mdx index 2fc0510be..188323118 100644 --- a/docs/databases/cassandra.mdx +++ b/docs/databases/cassandra.mdx @@ -36,6 +36,10 @@ See [Connection URL Reference](/databases/connection-urls#cassandra--scylladb) f **DataStax Astra DB**: Port 29042, use Client ID/Secret, needs Secure Connect Bundle (SSL/TLS) **Remote**: Use [SSH tunneling](/databases/ssh-tunneling) for production +## Amazon Keyspaces (IAM) + +Set **Authentication** to an AWS IAM mode (Access Key, Profile, or SSO) to connect to Amazon Keyspaces with SigV4 signing instead of a password. Enter the AWS region and enable TLS, which Keyspaces requires (use the `cassandra.{region}.amazonaws.com` endpoint on port 9142). Profiles resolve from `~/.aws/config` and `~/.aws/credentials`, including `credential_process`, SSO, and assumed roles. + **SSL/TLS**: The Cassandra driver has no TLS fallback. **Preferred** behaves the same as **Required** (the SSL pane shows a warning). Use **Required** for AstraDB, **Verify CA** with a CA certificate path for private PKI. For mutual TLS, set the client certificate and key paths; if the key is encrypted, enter its passphrase in the **Key Passphrase** field (stored in the Keychain). See [SSL/TLS](/features/ssl) for details. ## Features diff --git a/docs/databases/dynamodb.mdx b/docs/databases/dynamodb.mdx index 9762b8522..5d1358d99 100644 --- a/docs/databases/dynamodb.mdx +++ b/docs/databases/dynamodb.mdx @@ -15,9 +15,9 @@ Click **New Connection**, select **DynamoDB**, choose auth method (Access Key, A **Access Key + Secret Key**: IAM credentials. Optional session token for temporary creds from STS. -**AWS Profile**: Read from `~/.aws/credentials`. Format: `[profile_name]` with `aws_access_key_id`, `aws_secret_access_key`, optional `aws_session_token`. +**AWS Profile**: Reads the named profile from both `~/.aws/config` and `~/.aws/credentials`, the same way the AWS CLI does. A profile can supply static keys (`aws_access_key_id`, `aws_secret_access_key`, optional `aws_session_token`) or a `credential_process` command. Profiles defined only in `~/.aws/config` (as `[profile name]`) now resolve. -**AWS SSO**: Use cached credentials from `~/.aws/cli/cache/`. Run `aws sso login --profile my-sso-profile` before connecting. Credentials expire (re-login if auth fails). +**AWS SSO**: Reads the cached SSO token from `~/.aws/sso/cache/`. Run `aws sso login --profile my-sso-profile` before connecting. Tokens expire; the connection re-resolves credentials automatically and tells you to re-run `aws sso login` when the session has ended. ## Connection Settings @@ -43,7 +43,7 @@ Config: Auth Method = Access Key, Access Key/Secret = any non-empty, Custom Endp ## Example Configurations **AWS Production**: Standard Access Key method with credentials from IAM -**AWS Profile**: Select profile from `~/.aws/credentials` +**AWS Profile**: Select a profile from `~/.aws/config` or `~/.aws/credentials` **DynamoDB Local**: Fake credentials (non-empty required), Custom Endpoint = `http://localhost:8000` ## Features diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index d8bc1aeca..93f5e294b 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -39,6 +39,10 @@ Open URLs like `redis://:password@host:6379/0` or `rediss://` (TLS) from your br **Redis Cloud**: Requires SSL/TLS, enable in connection form **Remote**: Use [SSH tunneling](/databases/ssh-tunneling) for production +## Amazon ElastiCache (IAM) + +Set **Authentication** to an AWS IAM mode (Access Key, Profile, or SSO) to connect to an IAM-enabled ElastiCache cache. TablePro generates a short-lived IAM auth token and uses it as the Redis password; the username is your IAM-enabled Redis user. Enter the AWS region and the cache name (replication group ID), and enable TLS, which ElastiCache IAM requires. Profiles resolve from `~/.aws/config` and `~/.aws/credentials`, including `credential_process`, SSO, and assumed roles. + ## SSL/TLS Configure in the **SSL/TLS** pane of the connection form. Managed services like Upstash and Redis Cloud require TLS. Use `rediss://` for URL imports.