diff --git a/apple/Configs/PluginKit.xcconfig b/apple/Configs/PluginKit.xcconfig index 9f5ac3b3d0..8ed03963ae 100644 --- a/apple/Configs/PluginKit.xcconfig +++ b/apple/Configs/PluginKit.xcconfig @@ -1,3 +1,3 @@ // When to raise each, and how the build stamps them: .claude/rules/plugin-system.md, ABI. -TABLEPRO_PLUGINKIT_VERSION = 39 +TABLEPRO_PLUGINKIT_VERSION = 40 TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19 diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index c7fdd087a2..1c618718be 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -49,7 +49,9 @@ public final class ConnectionManager: @unchecked Sendable { throw ConnectionError.previousSessionStillClosing } guard isCurrentAttempt(generation, for: connection.id) else { throw CancellationError() } - let password = try secureStore.retrieve(forKey: Self.passwordKey(for: connection.id)) + let stored = try secureStore.retrieve(forKey: Self.passwordKey(for: connection.id)) + let password = try await driverFactory.resolvePassword(for: connection, stored: stored, secureStore: secureStore) + guard isCurrentAttempt(generation, for: connection.id) else { throw CancellationError() } var effectiveHost = connection.host var effectivePort = connection.port @@ -75,7 +77,11 @@ public final class ConnectionManager: @unchecked Sendable { effectiveConnection.host = effectiveHost effectiveConnection.port = effectivePort - let driver = try driverFactory.createDriver(for: effectiveConnection, password: password) + let driver = try driverFactory.createDriver( + for: effectiveConnection, + password: password, + preTunnelHost: tunnelId == nil ? nil : connection.host + ) try await driver.connect() let session = ConnectionSession( diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory+SignIn.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory+SignIn.swift new file mode 100644 index 0000000000..0546d3e960 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory+SignIn.swift @@ -0,0 +1,20 @@ +import Foundation +import TableProModels + +public extension DriverFactory { + func createDriver( + for connection: DatabaseConnection, + password: String?, + preTunnelHost: String? + ) throws -> any DatabaseDriver { + try createDriver(for: connection, password: password) + } + + func resolvePassword( + for connection: DatabaseConnection, + stored: String?, + secureStore: any SecureStore + ) async throws -> String? { + stored + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift index da68ce7933..685cf804d4 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift @@ -3,6 +3,18 @@ import TableProModels public protocol DriverFactory: Sendable { func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver + // `preTunnelHost` is the saved host when `connection.host` is a tunnel's loopback, else nil. + func createDriver( + for connection: DatabaseConnection, + password: String?, + preTunnelHost: String? + ) throws -> any DatabaseDriver + // Runs before the tunnel, on the connection as saved, so a sign-in failure comes before any SSH prompt. + func resolvePassword( + for connection: DatabaseConnection, + stored: String?, + secureStore: any SecureStore + ) async throws -> String? func supportedTypes() -> [DatabaseType] func refusal(for connection: DatabaseConnection) -> ConnectionError? } diff --git a/apple/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleCloudSQLLogin.swift b/apple/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleCloudSQLLogin.swift new file mode 100644 index 0000000000..0812db948f --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleCloudSQLLogin.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum GoogleCloudSQLLogin { + // What `gcloud sql generate-login-token` and the Cloud SQL connectors ask for. It allows a database + // sign-in and nothing else, so a token that leaks cannot call another Google API. + public static let scopes = ["https://www.googleapis.com/auth/sqlservice.login"] +} diff --git a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerSignInTests.swift b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerSignInTests.swift new file mode 100644 index 0000000000..3cd4e5309d --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerSignInTests.swift @@ -0,0 +1,198 @@ +import Foundation +@testable import TableProDatabase +import TableProModels +import TableProTestSupport +import Testing + +private let mockType = DatabaseType(rawValue: "mock") + +private struct MintFailure: Error {} + +private struct MintRequest: Sendable { + let host: String + let port: Int + let stored: String? +} + +private struct DriverRequest: Sendable { + let host: String + let port: Int + let password: String? + let preTunnelHost: String? +} + +private final class SigningDriverFactory: DriverFactory, @unchecked Sendable { + var mint: @Sendable (String?) async throws -> String? = { stored in stored } + private(set) var mintRequests: [MintRequest] = [] + private(set) var driverRequests: [DriverRequest] = [] + + func resolvePassword( + for connection: DatabaseConnection, + stored: String?, + secureStore: any SecureStore + ) async throws -> String? { + mintRequests.append(MintRequest(host: connection.host, port: connection.port, stored: stored)) + return try await mint(stored) + } + + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { + try createDriver(for: connection, password: password, preTunnelHost: nil) + } + + func createDriver( + for connection: DatabaseConnection, + password: String?, + preTunnelHost: String? + ) throws -> any DatabaseDriver { + driverRequests.append(DriverRequest( + host: connection.host, + port: connection.port, + password: password, + preTunnelHost: preTunnelHost + )) + return MockDatabaseDriver() + } + + func supportedTypes() -> [DatabaseType] { [mockType] } +} + +private final class PasswordRecordingFactory: DriverFactory, @unchecked Sendable { + private(set) var passwords: [String?] = [] + + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { + passwords.append(password) + return MockDatabaseDriver() + } + + func supportedTypes() -> [DatabaseType] { [mockType] } +} + +private func passwordKey(for connection: DatabaseConnection) -> String { + "com.TablePro.password.\(connection.id.uuidString)" +} + +private func rdsConnection(tunneled: Bool) -> DatabaseConnection { + DatabaseConnection( + name: "RDS", + type: mockType, + host: "mydb.abc123.us-east-1.rds.amazonaws.com", + port: 5_432, + username: "iam_user", + additionalFields: ["awsAuth": "accessKey"], + sshEnabled: tunneled, + sshConfiguration: tunneled ? SSHConfiguration(host: "bastion.example.com") : nil + ) +} + +@Suite(.timeLimit(.minutes(1))) +struct ConnectionManagerSignInTests { + @Test("The minted password reaches the driver in place of the stored one") + func mintedPasswordReachesTheDriver() async throws { + let connection = rdsConnection(tunneled: false) + let factory = SigningDriverFactory() + factory.mint = { _ in "signed-token" } + let manager = ConnectionManager( + driverFactory: factory, + secureStore: MockSecureStore(passwords: [passwordKey(for: connection): "stored-secret"]) + ) + + _ = try await manager.connect(connection) + + let mint = try #require(factory.mintRequests.first) + #expect(mint.stored == "stored-secret") + let driver = try #require(factory.driverRequests.first) + #expect(driver.password == "signed-token") + } + + @Test("A factory without sign-in still hands the stored password to its driver") + func defaultResolutionPassesTheStoredPassword() async throws { + let connection = rdsConnection(tunneled: false) + let factory = PasswordRecordingFactory() + let manager = ConnectionManager( + driverFactory: factory, + secureStore: MockSecureStore(passwords: [passwordKey(for: connection): "stored-secret"]) + ) + + _ = try await manager.connect(connection) + + #expect(factory.passwords == ["stored-secret"]) + } + + @Test("A mint failure throws before any tunnel opens, so no SSH prompt follows it") + func mintFailureThrowsBeforeTheTunnel() async throws { + let factory = SigningDriverFactory() + factory.mint = { _ in throw MintFailure() } + let ssh = MockSSHProvider() + let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore(), sshProvider: ssh) + let connection = rdsConnection(tunneled: true) + + await #expect(throws: MintFailure.self) { + _ = try await manager.connect(connection) + } + + #expect(ssh.openedTunnelIds.isEmpty) + #expect(factory.driverRequests.isEmpty) + #expect(manager.session(for: connection.id) == nil) + } + + @Test("An attempt invalidated during the mint throws CancellationError and opens no tunnel") + func attemptInvalidatedDuringTheMintStops() async throws { + let gate = AsyncGate() + let factory = SigningDriverFactory() + factory.mint = { _ in + await gate.wait() + return "signed-token" + } + let ssh = MockSSHProvider() + let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore(), sshProvider: ssh) + let connection = rdsConnection(tunneled: true) + + let attempt = Task { try await manager.connect(connection) } + await gate.arrived(1) + manager.invalidateAttempt(for: connection.id) + await gate.open() + + await #expect(throws: CancellationError.self) { try await attempt.value } + #expect(ssh.openedTunnelIds.isEmpty) + #expect(factory.driverRequests.isEmpty) + #expect(manager.session(for: connection.id) == nil) + } + + @Test("Through a tunnel, the mint sees the saved endpoint and the driver gets the saved host as preTunnelHost") + func tunneledConnectPassesTheSavedHost() async throws { + let factory = SigningDriverFactory() + let manager = ConnectionManager( + driverFactory: factory, + secureStore: MockSecureStore(), + sshProvider: MockSSHProvider() + ) + let connection = rdsConnection(tunneled: true) + + _ = try await manager.connect(connection) + + let mint = try #require(factory.mintRequests.first) + #expect(mint.host == connection.host) + #expect(mint.port == connection.port) + let driver = try #require(factory.driverRequests.first) + #expect(driver.host == "127.0.0.1") + #expect(driver.port == 33_306) + #expect(driver.preTunnelHost == connection.host) + } + + @Test("Without a tunnel, the driver dials the saved host and gets no preTunnelHost") + func directConnectHasNoPreTunnelHost() async throws { + let factory = SigningDriverFactory() + let manager = ConnectionManager( + driverFactory: factory, + secureStore: MockSecureStore(), + sshProvider: MockSSHProvider() + ) + let connection = rdsConnection(tunneled: false) + + _ = try await manager.connect(connection) + + let driver = try #require(factory.driverRequests.first) + #expect(driver.host == connection.host) + #expect(driver.preTunnelHost == nil) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSIAMTokenTargetTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSIAMTokenTargetTests.swift new file mode 100644 index 0000000000..c16eef5fa7 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSIAMTokenTargetTests.swift @@ -0,0 +1,118 @@ +import Foundation +import TableProPluginKit +import Testing + +struct AWSIAMTokenTargetTests { + private let credentials = AWSCredentials( + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + sessionToken: nil + ) + private let fixedDate = Date(timeIntervalSince1970: 1_440_938_160) + private let cacheHost = "my-cache.abc123.use1.cache.amazonaws.com" + private let cacheFields = ["awsRegion": "us-east-1", "awsReplicationGroupId": "my-cache"] + + private func token(_ target: AWSIAMTokenTarget, fields: [String: String] = [:]) throws -> String { + try target.token(username: "iam_user", fields: fields, credentials: credentials, now: fixedDate) + } + + private func rds( + host: String = "mydb.abc123.us-east-1.rds.amazonaws.com", + port: Int = 5_432, + preTunnelHost: String? = nil, + preTunnelPort: Int? = nil + ) -> AWSIAMTokenTarget { + .rds( + configuredHost: host, + configuredPort: port, + preTunnelHost: preTunnelHost, + preTunnelPort: preTunnelPort, + defaultPort: 5_432 + ) + } + + @Test("Redis with SSL off throws the requires-TLS error") + func elastiCacheWithTLSOffThrows() { + let requiresTLS = AWSAuthError.missingConfiguration( + String(localized: "ElastiCache IAM authentication requires TLS. Enable SSL in the connection's SSL settings.") + ) + #expect(throws: requiresTLS) { + _ = try token(.elastiCache(host: cacheHost, tlsDisabled: true), fields: cacheFields) + } + } + + @Test("Redis with SSL on signs the cache name") + func elastiCacheWithTLSOnSigns() throws { + let expected = ElastiCacheAuthTokenGenerator.generateToken( + replicationGroupId: "my-cache", + region: "us-east-1", + userId: "iam_user", + credentials: credentials, + now: fixedDate + ) + #expect(try token(.elastiCache(host: cacheHost, tlsDisabled: false), fields: cacheFields) == expected) + } + + @Test("ElastiCache asks for the region before TLS, and for the cache name after it") + func elastiCacheCheckOrder() { + #expect(throws: AWSAuthError.regionUnknown(host: cacheHost)) { + _ = try token(.elastiCache(host: cacheHost, tlsDisabled: true), fields: ["awsReplicationGroupId": "my-cache"]) + } + let missingCacheName = AWSAuthError.missingConfiguration( + String(localized: "Enter the ElastiCache cache name (replication group ID) to use IAM authentication.") + ) + #expect(throws: missingCacheName) { + _ = try token(.elastiCache(host: cacheHost, tlsDisabled: false), fields: ["awsRegion": "us-east-1"]) + } + } + + @Test("A tunneled RDS connection signs the endpoint from before the tunnel") + func rdsTunneledSignsPreTunnelHost() throws { + let tunneled = try token(rds( + host: "127.0.0.1", + port: 62_000, + preTunnelHost: "mydb.abc123.us-east-1.rds.amazonaws.com", + preTunnelPort: 5_432 + )) + let direct = try token(rds()) + #expect(tunneled == direct) + #expect(tunneled.hasPrefix("mydb.abc123.us-east-1.rds.amazonaws.com:5432/?Action=connect&DBUser=iam_user&")) + } + + @Test("A host typed with capitals finds its region and signs as the lowercase endpoint") + func rdsMixedCaseHost() throws { + let mixedCase = try token(rds(host: "MyDB.abc123.US-EAST-1.RDS.amazonaws.com")) + let lowercase = try token(rds()) + #expect(mixedCase == lowercase) + } + + @Test("The AWS Region field wins over the region in the host") + func rdsExplicitRegionWins() throws { + let signed = try token(rds(), fields: ["awsRegion": "eu-west-1"]) + #expect(signed.contains("%2Feu-west-1%2Frds-db%2Faws4_request")) + } + + @Test("A host with no region and no AWS Region field is rejected") + func rdsRegionUnknown() { + #expect(throws: AWSAuthError.regionUnknown(host: "db.example.com")) { + _ = try token(rds(host: "db.example.com")) + } + } + + @Test("A local forward with no RDS Endpoint is rejected") + func rdsLoopbackRejected() { + #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "127.0.0.1")) { + _ = try token(rds(host: "127.0.0.1", port: 54_321)) + } + } + + @Test("The RDS Endpoint field signs the endpoint behind a local forward") + func rdsEndpointOverride() throws { + let signed = try token( + rds(host: "127.0.0.1", port: 54_321), + fields: ["awsRDSEndpoint": "mydb.abc123.ap-south-1.rds.amazonaws.com:5433"] + ) + #expect(signed.hasPrefix("mydb.abc123.ap-south-1.rds.amazonaws.com:5433/?")) + #expect(signed.contains("%2Fap-south-1%2Frds-db%2Faws4_request")) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSSSOLoginTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSSSOLoginTests.swift index 3c70843873..63abcc025b 100644 --- a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSSSOLoginTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSSSOLoginTests.swift @@ -1,5 +1,5 @@ import Foundation -import TableProPluginKit +@testable import TableProPluginKit import Testing struct AWSSSOLoginTests { @@ -53,4 +53,240 @@ struct AWSSSOLoginTests { #expect(object?["region"] == "us-east-1") #expect(object?["startUrl"] == "https://example.awsapps.com/start") } + + @Test("authorize registers, shows the code, polls past pending and slow_down, and returns the token") + func authorizeReturnsToken() async throws { + let script = deviceFlowScript(interval: 2, tokenReplies: [.pending, .slowDown, .issued]) + let clock = SteppingClock() + let presented = PresentedCodes() + let startedAt = Date() + + let entry = try await AWSSSOLogin.authorize( + region: "eu-west-1", + startUrl: "https://example.awsapps.com/start", + presentCode: { url, userCode in presented.record(url: url, userCode: userCode) }, + session: AWSSSOScriptedProtocol.session(for: script), + clock: clock + ) + + #expect(entry.accessToken == "AT") + #expect(entry.refreshToken == "RT") + #expect(entry.region == "eu-west-1") + #expect(entry.startUrl == "https://example.awsapps.com/start") + #expect(entry.clientId == "CID") + #expect(entry.clientSecret == "CSECRET") + #expect(entry.registrationExpiresAt == Date(timeIntervalSince1970: 1_893_456_000)) + let lifetime = entry.expiresAt.timeIntervalSince(startedAt) + #expect(lifetime >= 28_800 && lifetime < 28_800 + 60) + + let verificationURL = try #require(URL(string: "https://device.sso.eu-west-1.amazonaws.com/?user_code=ABCD-EFGH")) + #expect(presented.all == [PresentedCodes.Code(url: verificationURL, userCode: "ABCD-EFGH")]) + #expect(clock.sleeps == [.seconds(2), .seconds(2), .seconds(7)]) + + #expect(script.exchanges.allSatisfy { $0.request.url?.host == "oidc.eu-west-1.amazonaws.com" }) + let device = try #require(script.calls(to: "/device_authorization").first) + #expect(try jsonStrings(device.body) == [ + "clientId": "CID", + "clientSecret": "CSECRET", + "startUrl": "https://example.awsapps.com/start" + ]) + let polls = script.calls(to: "/token") + try #require(polls.count == 3) + #expect(try jsonStrings(polls[0].body) == [ + "clientId": "CID", + "clientSecret": "CSECRET", + "grantType": "urn:ietf:params:oauth:grant-type:device_code", + "deviceCode": "DC" + ]) + } + + @Test("authorize throws accessDenied when the user denies the request") + func authorizeDenied() async { + let script = deviceFlowScript(tokenReplies: [.pending, .denied]) + await #expect(throws: AWSSSOLoginError.accessDenied) { + _ = try await AWSSSOLogin.authorize( + region: "us-east-1", + startUrl: "https://example.awsapps.com/start", + presentCode: { _, _ in }, + session: AWSSSOScriptedProtocol.session(for: script), + clock: SteppingClock() + ) + } + } + + @Test("authorize stops polling when the device code expires") + func authorizeTimesOut() async { + let script = deviceFlowScript(interval: 1, expiresIn: 3, tokenReplies: [.pending]) + let clock = SteppingClock() + await #expect(throws: AWSSSOLoginError.authorizationTimedOut) { + _ = try await AWSSSOLogin.authorize( + region: "us-east-1", + startUrl: "https://example.awsapps.com/start", + presentCode: { _, _ in }, + session: AWSSSOScriptedProtocol.session(for: script), + clock: clock + ) + } + #expect(clock.sleeps == [.seconds(1), .seconds(1), .seconds(1)]) + #expect(script.calls(to: "/token").count == 3) + } + + @Test("authorize reports why AWS refused the start URL, before showing any code") + func authorizeReportsRefusedStartUrl() async { + let refusal = AWSSSOScriptedProtocol.Reply.json( + #"{"error":"invalid_request","error_description":"Invalid start url"}"#, + status: 400 + ) + let script = deviceFlowScript(deviceAuthorization: refusal, tokenReplies: [.issued]) + let presented = PresentedCodes() + await #expect(throws: AWSSSOLoginError.serverError("Invalid start url")) { + _ = try await AWSSSOLogin.authorize( + region: "us-east-1", + startUrl: "https://typo.awsapps.com/start", + presentCode: { url, userCode in presented.record(url: url, userCode: userCode) }, + session: AWSSSOScriptedProtocol.session(for: script), + clock: SteppingClock() + ) + } + #expect(presented.all.isEmpty) + #expect(script.calls(to: "/token").isEmpty) + } + + @Test("login signs in through authorize and writes the CLI cache file for the sso-session") + func loginWritesTokenCache() async throws { + let config = """ + [sso-session work] + sso_start_url = https://example.awsapps.com/start + sso_region = eu-west-1 + + [profile dev] + sso_session = work + sso_account_id = 111111111111 + sso_role_name = Developer + """ + let cacheDirectory = NSTemporaryDirectory() + "AWSSSOLoginTests_\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: cacheDirectory) } + let script = deviceFlowScript(tokenReplies: [.issued]) + let presented = PresentedCodes() + + try await AWSSSOLogin.login( + profileName: "dev", + configContents: config, + cacheDirectory: cacheDirectory, + openVerificationURL: { url in presented.record(url: url, userCode: "") }, + session: AWSSSOScriptedProtocol.session(for: script), + clock: SteppingClock() + ) + + #expect(presented.all.map { $0.url.absoluteString } == [ + "https://device.sso.eu-west-1.amazonaws.com/?user_code=ABCD-EFGH" + ]) + let path = (cacheDirectory as NSString).appendingPathComponent(AWSSSO.sha1Hex(Data("work".utf8)) + ".json") + let data = try #require(FileManager.default.contents(atPath: path)) + let cache = try jsonStrings(data) + #expect(cache["accessToken"] == "AT") + #expect(cache["refreshToken"] == "RT") + #expect(cache["region"] == "eu-west-1") + #expect(cache["startUrl"] == "https://example.awsapps.com/start") + #expect(cache["clientId"] == "CID") + #expect(cache["clientSecret"] == "CSECRET") + } +} + +private extension AWSSSOScriptedProtocol.Reply { + static let pending = json(#"{"error":"authorization_pending"}"#, status: 400) + static let slowDown = json(#"{"error":"slow_down"}"#, status: 400) + static let denied = json(#"{"error":"access_denied"}"#, status: 400) + static let issued = json(#"{"accessToken":"AT","expiresIn":28800,"refreshToken":"RT","tokenType":"Bearer"}"#) +} + +private func deviceFlowScript( + interval: Int = 1, + expiresIn: Int = 600, + deviceAuthorization: AWSSSOScriptedProtocol.Reply? = nil, + tokenReplies: [AWSSSOScriptedProtocol.Reply] +) -> AWSSSOScriptedProtocol.Script { + let authorization = deviceAuthorization ?? AWSSSOScriptedProtocol.Reply.json(#""" + {"deviceCode":"DC","userCode":"ABCD-EFGH", + "verificationUri":"https://device.sso.eu-west-1.amazonaws.com/", + "verificationUriComplete":"https://device.sso.eu-west-1.amazonaws.com/?user_code=ABCD-EFGH", + "expiresIn":\#(expiresIn),"interval":\#(interval)} + """#) + return AWSSSOScriptedProtocol.Script { request, callIndex in + switch request.url?.path { + case "/client/register"?: + return .json(#"{"clientId":"CID","clientSecret":"CSECRET","clientSecretExpiresAt":1893456000}"#) + case "/device_authorization"?: + return authorization + case "/token"?: + return tokenReplies[min(callIndex, tokenReplies.count - 1)] + default: + return .json(#"{"error":"not_found"}"#, status: 404) + } + } +} + +private func jsonStrings(_ data: Data) throws -> [String: String] { + try #require(JSONSerialization.jsonObject(with: data) as? [String: String]) +} + +private final class PresentedCodes: @unchecked Sendable { + struct Code: Equatable { + let url: URL + let userCode: String + } + + private let lock = NSLock() + private var recorded: [Code] = [] + + var all: [Code] { + lock.withLock { recorded } + } + + func record(url: URL, userCode: String) { + lock.withLock { recorded.append(Code(url: url, userCode: userCode)) } + } +} + +// Each sleep returns at once and moves time to its deadline, so a poll loop runs without waiting. +private final class SteppingClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol { + let offset: Duration + + func advanced(by duration: Duration) -> Instant { + Instant(offset: offset + duration) + } + + func duration(to other: Instant) -> Duration { + other.offset - offset + } + + static func < (lhs: Instant, rhs: Instant) -> Bool { + lhs.offset < rhs.offset + } + } + + private let lock = NSLock() + private var current = Instant(offset: .zero) + private var recorded: [Duration] = [] + + var now: Instant { + lock.withLock { current } + } + + var minimumResolution: Duration { + .zero + } + + var sleeps: [Duration] { + lock.withLock { recorded } + } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + try Task.checkCancellation() + lock.withLock { + recorded.append(current.duration(to: deadline)) + current = max(current, deadline) + } + } } diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSSSOPortalTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSSSOPortalTests.swift new file mode 100644 index 0000000000..8eeb81c5d9 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/AWSSSOPortalTests.swift @@ -0,0 +1,238 @@ +import Foundation +import TableProPluginKit +import Testing + +struct AWSSSOPortalTests { + @Test("listAccounts follows nextToken to the last page") + func listAccountsFollowsNextToken() async throws { + let script = AWSSSOScriptedProtocol.Script { request, _ in + switch queryValues(of: request)["next_token"] { + case nil: + return .json(#""" + {"accountList":[{"accountId":"111111111111","accountName":"Production","emailAddress":"prod@example.com"}], + "nextToken":"page+2/=="} + """#) + case "page+2/=="?: + return .json(#"{"accountList":[{"accountId":"222222222222","accountName":"Staging"}],"nextToken":null}"#) + default: + return .json(#"{"message":"unknown token"}"#, status: 400) + } + } + + let accounts = try await AWSSSOPortal.listAccounts( + region: "eu-west-1", + accessToken: "BEARER", + session: AWSSSOScriptedProtocol.session(for: script) + ) + + #expect(accounts == [ + AWSSSOAccount(accountId: "111111111111", accountName: "Production", emailAddress: "prod@example.com"), + AWSSSOAccount(accountId: "222222222222", accountName: "Staging") + ]) + let requests = script.exchanges.map(\.request) + try #require(requests.count == 2) + for request in requests { + #expect(request.httpMethod == "GET") + #expect(request.url?.host == "portal.sso.eu-west-1.amazonaws.com") + #expect(request.url?.path == "/assignment/accounts") + #expect(request.value(forHTTPHeaderField: "x-amz-sso_bearer_token") == "BEARER") + #expect(queryValues(of: request)["max_result"] == "100") + } + #expect(queryValues(of: requests[0])["next_token"] == nil) + let secondQuery = try #require(requests.last?.url.flatMap { + URLComponents(url: $0, resolvingAgainstBaseURL: false)?.percentEncodedQuery + }) + #expect(secondQuery.contains("next_token=page%2B2%2F%3D%3D")) + } + + @Test("listAccountRoles follows nextToken and keeps the account on every page") + func listAccountRolesFollowsNextToken() async throws { + let script = AWSSSOScriptedProtocol.Script { request, _ in + switch queryValues(of: request)["next_token"] { + case nil: + return .json(#"{"roleList":[{"accountId":"111111111111","roleName":"ReadOnly"}],"nextToken":"roles-2"}"#) + case "roles-2"?: + return .json(#"{"roleList":[{"roleName":"Admin"}]}"#) + default: + return .json(#"{"message":"unknown token"}"#, status: 400) + } + } + + let roles = try await AWSSSOPortal.listAccountRoles( + region: "us-east-1", + accessToken: "BEARER", + accountId: "111111111111", + session: AWSSSOScriptedProtocol.session(for: script) + ) + + #expect(roles == [ + AWSSSOAccountRole(accountId: "111111111111", roleName: "ReadOnly"), + AWSSSOAccountRole(accountId: "111111111111", roleName: "Admin") + ]) + let requests = script.exchanges.map(\.request) + try #require(requests.count == 2) + for request in requests { + #expect(request.url?.host == "portal.sso.us-east-1.amazonaws.com") + #expect(request.url?.path == "/assignment/roles") + #expect(request.value(forHTTPHeaderField: "x-amz-sso_bearer_token") == "BEARER") + #expect(queryValues(of: request)["account_id"] == "111111111111") + #expect(queryValues(of: request)["max_result"] == "100") + } + #expect(queryValues(of: requests[1])["next_token"] == "roles-2") + } + + @Test("A rejected token reports the portal's own message") + func rejectedTokenReportsPortalMessage() async { + let errorType = "UnauthorizedException:http://internal.amazon.com/coral/com.amazonaws.switchboard.portal/" + let withMessage = AWSSSOScriptedProtocol.Script { _, _ in + .json(#"{"message":"Session token not found or invalid"}"#, status: 401, headers: ["x-amzn-ErrorType": errorType]) + } + await #expect(throws: AWSSSOLoginError.serverError("Session token not found or invalid")) { + _ = try await AWSSSOPortal.listAccounts( + region: "us-east-1", + accessToken: "EXPIRED", + session: AWSSSOScriptedProtocol.session(for: withMessage) + ) + } + + let typeOnly = AWSSSOScriptedProtocol.Script { _, _ in + .init(status: 401, headers: ["x-amzn-ErrorType": errorType]) + } + await #expect(throws: AWSSSOLoginError.serverError("UnauthorizedException")) { + _ = try await AWSSSOPortal.listAccountRoles( + region: "us-east-1", + accessToken: "EXPIRED", + accountId: "111111111111", + session: AWSSSOScriptedProtocol.session(for: typeOnly) + ) + } + } + + @Test("A nextToken the portal already returned stops the listing") + func repeatedNextTokenStops() async { + let script = AWSSSOScriptedProtocol.Script { _, _ in + .json(#"{"accountList":[{"accountId":"111111111111"}],"nextToken":"same"}"#) + } + await #expect(throws: AWSSSOLoginError.unexpectedResponse) { + _ = try await AWSSSOPortal.listAccounts( + region: "us-east-1", + accessToken: "BEARER", + session: AWSSSOScriptedProtocol.session(for: script) + ) + } + #expect(script.exchanges.count == 2) + } +} + +private func queryValues(of request: URLRequest) -> [String: String] { + guard let url = request.url, + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + else { return [:] } + return Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { _, last in last }) +} + +// Each session carries its own route header, so cases running in parallel never read each other's +// replies or requests through the shared protocol class. +final class AWSSSOScriptedProtocol: URLProtocol, @unchecked Sendable { + struct Reply: Sendable { + var status = 200 + var body = Data() + var headers: [String: String] = [:] + + static func json(_ text: String, status: Int = 200, headers: [String: String] = [:]) -> Reply { + Reply(status: status, body: Data(text.utf8), headers: headers) + } + } + + struct Exchange { + let request: URLRequest + let body: Data + } + + final class Script: @unchecked Sendable { + typealias Respond = @Sendable (_ request: URLRequest, _ callIndex: Int) -> Reply + + private let respond: Respond + private let lock = NSLock() + private var recorded: [Exchange] = [] + + init(_ respond: @escaping Respond) { + self.respond = respond + } + + var exchanges: [Exchange] { + lock.withLock { recorded } + } + + func calls(to path: String) -> [Exchange] { + exchanges.filter { $0.request.url?.path == path } + } + + fileprivate func reply(to request: URLRequest, body: Data) -> Reply { + let callIndex = lock.withLock { () -> Int in + let index = recorded.filter { $0.request.url?.path == request.url?.path }.count + recorded.append(Exchange(request: request, body: body)) + return index + } + return respond(request, callIndex) + } + } + + private static let routeHeader = "X-AWSSSO-Scripted-Route" + private static let lock = NSLock() + nonisolated(unsafe) private static var scripts: [String: Script] = [:] + + static func session(for script: Script) -> URLSession { + let id = UUID().uuidString + lock.withLock { scripts[id] = script } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AWSSSOScriptedProtocol.self] + configuration.httpAdditionalHeaders = [routeHeader: id] + return URLSession(configuration: configuration) + } + + private static func script(for request: URLRequest) -> Script? { + guard let id = request.value(forHTTPHeaderField: routeHeader) else { return nil } + return lock.withLock { scripts[id] } + } + + // URLSession hands a custom protocol the body as a stream, not as httpBody. + private static func body(of request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4_096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let script = Self.script(for: request), let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable)) + return + } + let reply = script.reply(to: request, body: Self.body(of: request)) + var headers = reply.headers + headers["Content-Type"] = "application/json" + guard let response = HTTPURLResponse( + url: url, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: headers + ) else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: reply.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/RDSAuthTokenTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/RDSAuthTokenTests.swift new file mode 100644 index 0000000000..e5fd04c5ef --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/RDSAuthTokenTests.swift @@ -0,0 +1,262 @@ +import Foundation +@testable import TableProPluginKit +import Testing + +struct RDSAuthTokenGeneratorTests { + private let credentials = AWSCredentials( + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + sessionToken: nil + ) + private let fixedDate = Date(timeIntervalSince1970: 1_440_938_160) + + // Signed at fixedDate by botocore 1.42.97 (generate_db_auth_token) and by a SigV4 script written + // from the AWS spec; both gave these signatures. + private static let vectorHost = "mydb.abc123.us-east-1.rds.amazonaws.com" + private static let vectorToken = "mydb.abc123.us-east-1.rds.amazonaws.com:5432/?Action=connect&DBUser=iam_user" + + "&X-Amz-Algorithm=AWS4-HMAC-SHA256" + + "&X-Amz-Credential=AKIDEXAMPLE%2F20150830%2Fus-east-1%2Frds-db%2Faws4_request" + + "&X-Amz-Date=20150830T123600Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host" + + "&X-Amz-Signature=ad0215c0a1ee37ec044c9a9f4eac788e0f258274ef4a5e53460c3c74c4e824ac" + private static let vectorTokenWithSession = "mydb.abc123.us-east-1.rds.amazonaws.com:5432/?Action=connect&DBUser=iam_user" + + "&X-Amz-Algorithm=AWS4-HMAC-SHA256" + + "&X-Amz-Credential=AKIDEXAMPLE%2F20150830%2Fus-east-1%2Frds-db%2Faws4_request" + + "&X-Amz-Date=20150830T123600Z&X-Amz-Expires=900&X-Amz-Security-Token=FQoGZXIvYXdzEXAMPLE" + + "&X-Amz-SignedHeaders=host" + + "&X-Amz-Signature=517415e469bb2865f3347f18d1da5359f2694c79f7522598609c60836479f868" + + private func makeToken(host: String = "mydb.us-east-1.rds.amazonaws.com", sessionToken: String? = nil) -> String { + RDSAuthTokenGenerator.generateToken( + host: host, + port: 5_432, + region: "us-east-1", + username: "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("mydb.us-east-1.rds.amazonaws.com:5432/?")) + #expect(token.contains("Action=connect")) + #expect(token.contains("DBUser=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("%2Frds-db%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")) + } + + @Test("The token matches one signed by an independent SigV4 implementation") + func matchesIndependentVector() { + #expect(makeToken(host: Self.vectorHost) == Self.vectorToken) + #expect(makeToken(host: Self.vectorHost, sessionToken: "FQoGZXIvYXdzEXAMPLE") == Self.vectorTokenWithSession) + } + + @Test("A host typed with capitals signs as the lowercase endpoint") + func mixedCaseHostSignsLowercase() { + #expect(makeToken(host: "MyDB.ABC123.us-east-1.RDS.AmazonAWS.com") == Self.vectorToken) + } +} + +struct RDSEndpointTests { + @Test("Derives region from cluster hostname") + func clusterHostname() { + #expect(RDSEndpoint.region(forHost: "mydb.cluster-abc123.us-east-1.rds.amazonaws.com") == "us-east-1") + } + + @Test("Derives region from instance hostname") + func instanceHostname() { + #expect(RDSEndpoint.region(forHost: "mydb.abc123.us-west-2.rds.amazonaws.com") == "us-west-2") + } + + @Test("Derives region from China partition hostname") + func chinaHostname() { + #expect(RDSEndpoint.region(forHost: "mydb.abc.cn-north-1.rds.amazonaws.com.cn") == "cn-north-1") + } + + @Test("Returns nil for non-RDS hosts") + func nonRDSHost() { + #expect(RDSEndpoint.region(forHost: "localhost") == nil) + #expect(RDSEndpoint.region(forHost: "db.example.com") == nil) + } + + @Test("Derives a lowercase region from a hostname typed with capitals") + func mixedCaseHostname() { + #expect(RDSEndpoint.region(forHost: "MyDB.abc123.US-EAST-1.RDS.AmazonAWS.com") == "us-east-1") + #expect(RDSEndpoint.region(forHost: "MyDB.Cluster-ABC.EU-West-1.Rds.Amazonaws.Com") == "eu-west-1") + } +} + +struct RDSSigningEndpointResolverTests { + private func resolve( + host: String = "mydb.abc123.us-east-1.rds.amazonaws.com", + port: Int = 5_432, + preTunnelHost: String? = nil, + preTunnelPort: Int? = nil, + override: String? = nil, + defaultPort: Int = 5_432 + ) throws -> RDSSigningEndpoint { + try RDSSigningEndpointResolver.resolve( + configuredHost: host, + configuredPort: port, + preTunnelHost: preTunnelHost, + preTunnelPort: preTunnelPort, + override: override, + defaultPort: defaultPort + ) + } + + @Test("A direct connection signs its own endpoint") + func directConnection() throws { + let endpoint = try resolve() + #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.us-east-1.rds.amazonaws.com", port: 5_432)) + } + + @Test("A tunneled connection signs the endpoint from before the rewrite") + func tunneledConnection() throws { + let endpoint = try resolve( + host: "127.0.0.1", + port: 62_000, + preTunnelHost: "mydb.abc123.us-east-1.rds.amazonaws.com", + preTunnelPort: 5_432 + ) + #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.us-east-1.rds.amazonaws.com", port: 5_432)) + } + + @Test("The endpoint override wins over the tunnel and the configured host") + func overrideWins() throws { + let endpoint = try resolve( + host: "127.0.0.1", + port: 62_000, + preTunnelHost: "old.abc123.us-east-1.rds.amazonaws.com", + preTunnelPort: 5_432, + override: "mydb.abc123.ap-south-1.rds.amazonaws.com:5433" + ) + #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.ap-south-1.rds.amazonaws.com", port: 5_433)) + } + + @Test("An override without a port falls back to the engine default") + func overrideWithoutPort() throws { + let endpoint = try resolve( + host: "127.0.0.1", + port: 54_321, + override: "mydb.abc123.ap-south-1.rds.amazonaws.com", + defaultPort: 5_432 + ) + #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.ap-south-1.rds.amazonaws.com", port: 5_432)) + } + + @Test("A blank override is ignored") + func blankOverrideIgnored() throws { + let endpoint = try resolve(override: " ") + #expect(endpoint.host == "mydb.abc123.us-east-1.rds.amazonaws.com") + } + + @Test("An override keeps only the host and port of a pasted URL") + func overrideStripsSchemeAndPath() throws { + let endpoint = try resolve(override: "postgresql://mydb.abc123.us-east-1.rds.amazonaws.com:5432/postgres") + #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.us-east-1.rds.amazonaws.com", port: 5_432)) + } + + @Test("A local forward with no override is rejected before the token is signed") + func loopbackIsRejected() { + for host in ["127.0.0.1", "localhost", "::1", "127.0.0.53"] { + #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: host)) { + _ = try resolve(host: host, port: 54_321) + } + } + } + + @Test("A tunnel to a loopback destination is rejected too") + func loopbackPreTunnelHostIsRejected() { + #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "localhost")) { + _ = try resolve(host: "127.0.0.1", port: 62_000, preTunnelHost: "localhost", preTunnelPort: 5_432) + } + } + + @Test("A malformed override is rejected with the value the user typed") + func malformedOverrideIsRejected() { + for value in ["mydb.rds.amazonaws.com:port", "mydb.rds.amazonaws.com:0", ":5432", "127.0.0.1:5432"] { + #expect(throws: AWSAuthError.rdsEndpointInvalid(value)) { + _ = try resolve(override: value) + } + } + } + + @Test("An override may not redirect the token away from the host being dialled") + func overrideCannotRedirectToAnotherHost() { + #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "evil.example.com")) { + _ = try resolve( + host: "evil.example.com", + port: 5_432, + override: "prod.abc123.us-east-1.rds.amazonaws.com:5432" + ) + } + } + + @Test("An override that restates the dialled host is accepted") + func overrideMatchingDialledHostIsAccepted() throws { + let endpoint = try resolve( + host: "mydb.abc123.us-east-1.rds.amazonaws.com", + port: 5_432, + override: "MyDB.abc123.us-east-1.RDS.amazonaws.com:5433" + ) + #expect(endpoint.port == 5_433) + } + + @Test("A pre-tunnel host is ignored when the socket is not a local forward") + func preTunnelHostIgnoredForRemoteDial() throws { + let endpoint = try resolve( + host: "evil.example.com", + port: 5_432, + preTunnelHost: "prod.abc123.us-east-1.rds.amazonaws.com", + preTunnelPort: 5_432 + ) + #expect(endpoint == RDSSigningEndpoint(host: "evil.example.com", port: 5_432)) + } + + @Test("The region comes from the signing host, not the local forward") + func regionFollowsSigningHost() throws { + let endpoint = try resolve( + host: "127.0.0.1", + port: 62_000, + preTunnelHost: "mydb.abc123.ap-south-1.rds.amazonaws.com", + preTunnelPort: 5_432 + ) + #expect(RDSEndpoint.region(forHost: endpoint.host) == "ap-south-1") + } + + @Test("A host typed with capitals resolves to the lowercase endpoint") + func mixedCaseHostResolvesLowercase() throws { + let lowercase = RDSSigningEndpoint(host: "mydb.abc123.us-east-1.rds.amazonaws.com", port: 5_432) + #expect(try resolve(host: "MyDB.abc123.US-EAST-1.rds.amazonaws.com") == lowercase) + #expect(try resolve( + host: "127.0.0.1", + port: 62_000, + preTunnelHost: "MyDB.abc123.US-EAST-1.rds.amazonaws.com", + preTunnelPort: 5_432 + ) == lowercase) + #expect(try resolve(host: "127.0.0.1", port: 62_000, override: "MyDB.abc123.US-EAST-1.RDS.amazonaws.com:5432") == lowercase) + } +} diff --git a/apple/Plugins/TableProPluginKit/AWS/AWSIAMTokenTarget.swift b/apple/Plugins/TableProPluginKit/AWS/AWSIAMTokenTarget.swift new file mode 100644 index 0000000000..12e6444385 --- /dev/null +++ b/apple/Plugins/TableProPluginKit/AWS/AWSIAMTokenTarget.swift @@ -0,0 +1,68 @@ +import Foundation + +public enum AWSIAMTokenTarget: Sendable, Equatable { + case elastiCache(host: String, tlsDisabled: Bool) + case rds( + configuredHost: String, + configuredPort: Int, + preTunnelHost: String?, + preTunnelPort: Int?, + defaultPort: Int + ) + + public func token( + username: String, + fields: [String: String], + credentials: AWSCredentials, + now: Date = Date() + ) throws -> String { + switch self { + case .elastiCache(let host, let tlsDisabled): + guard let region = Self.nonEmpty(fields["awsRegion"]) else { + throw AWSAuthError.regionUnknown(host: host) + } + guard !tlsDisabled else { + throw AWSAuthError.missingConfiguration( + String(localized: "ElastiCache IAM authentication requires TLS. Enable SSL in the connection's SSL settings.") + ) + } + guard let replicationGroupId = Self.nonEmpty(fields["awsReplicationGroupId"]) 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: username, + credentials: credentials, + now: now + ) + case .rds(let configuredHost, let configuredPort, let preTunnelHost, let preTunnelPort, let defaultPort): + let endpoint = try RDSSigningEndpointResolver.resolve( + configuredHost: configuredHost, + configuredPort: configuredPort, + preTunnelHost: preTunnelHost, + preTunnelPort: preTunnelPort, + override: fields["awsRDSEndpoint"], + defaultPort: defaultPort + ) + guard let region = Self.nonEmpty(fields["awsRegion"]) ?? RDSEndpoint.region(forHost: endpoint.host) else { + throw AWSAuthError.regionUnknown(host: endpoint.host) + } + return RDSAuthTokenGenerator.generateToken( + host: endpoint.host, + port: endpoint.port, + region: region, + username: username, + credentials: credentials, + now: now + ) + } + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} diff --git a/apple/Plugins/TableProPluginKit/AWS/AWSSSOLogin.swift b/apple/Plugins/TableProPluginKit/AWS/AWSSSOLogin.swift index f9cf793af9..03f9656d7c 100644 --- a/apple/Plugins/TableProPluginKit/AWS/AWSSSOLogin.swift +++ b/apple/Plugins/TableProPluginKit/AWS/AWSSSOLogin.swift @@ -249,25 +249,79 @@ public enum AWSSSOLogin { openVerificationURL: @escaping @Sendable (URL) -> Void, session: URLSession = .shared ) async throws { + try await login( + profileName: profileName, + configContents: configContents, + cacheDirectory: cacheDirectory, + openVerificationURL: openVerificationURL, + session: session, + clock: ContinuousClock() + ) + } + + static func login( + profileName: String, + configContents: String, + cacheDirectory: String, + openVerificationURL: @escaping @Sendable (URL) -> Void, + session: URLSession, + clock: C + ) async throws where C.Duration == Duration { 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( + let entry = try await authorize( region: settings.region, + startUrl: settings.startUrl, + presentCode: { url, _ in openVerificationURL(url) }, + session: session, + clock: clock + ) + try writeTokenCache( + cacheKey: settings.ssoSession ?? settings.startUrl, + entry: entry, + cacheDirectory: cacheDirectory + ) + } + + public static func authorize( + region: String, + startUrl: String, + presentCode: @escaping @Sendable (URL, String) -> Void, + session: URLSession = .shared + ) async throws -> AWSSSOTokenCacheEntry { + try await authorize( + region: region, + startUrl: startUrl, + presentCode: presentCode, + session: session, + clock: ContinuousClock() + ) + } + + static func authorize( + region: String, + startUrl: String, + presentCode: @escaping @Sendable (URL, String) -> Void, + session: URLSession, + clock: C + ) async throws -> AWSSSOTokenCacheEntry where C.Duration == Duration { + let registration = try await registerClient(region: region, clientName: "TablePro", session: session) + let auth = try await startDeviceAuthorization( + region: region, clientId: registration.clientId, clientSecret: registration.clientSecret, - startUrl: settings.startUrl, + startUrl: startUrl, session: session ) - if let url = URL(string: auth.verificationUriComplete) { - openVerificationURL(url) + if let url = URL(string: auth.verificationUriComplete) ?? URL(string: auth.verificationUri) { + presentCode(url, auth.userCode) } - let deadline = Date().addingTimeInterval(TimeInterval(auth.expiresIn)) + let deadline = clock.now.advanced(by: .seconds(auth.expiresIn)) var interval = max(auth.interval, 1) - while Date() < deadline { - try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + while clock.now < deadline { + try await clock.sleep(for: .seconds(interval)) let outcome = try await pollToken( - region: settings.region, + region: region, clientId: registration.clientId, clientSecret: registration.clientSecret, deviceCode: auth.deviceCode, @@ -275,21 +329,16 @@ public enum AWSSSOLogin { ) switch outcome.poll { case .token(let accessToken, let expiresIn): - try writeTokenCache( - cacheKey: settings.ssoSession ?? settings.startUrl, - entry: AWSSSOTokenCacheEntry( - accessToken: accessToken, - expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)), - region: settings.region, - startUrl: settings.startUrl, - refreshToken: outcome.refreshToken, - clientId: registration.clientId, - clientSecret: registration.clientSecret, - registrationExpiresAt: registration.registrationExpiresAt - ), - cacheDirectory: cacheDirectory + return AWSSSOTokenCacheEntry( + accessToken: accessToken, + expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)), + region: region, + startUrl: startUrl, + refreshToken: outcome.refreshToken, + clientId: registration.clientId, + clientSecret: registration.clientSecret, + registrationExpiresAt: registration.registrationExpiresAt ) - return case .pending: continue case .slowDown: @@ -361,11 +410,29 @@ public enum AWSSSOLogin { request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) + let data: Data + let response: URLResponse do { - let (data, _) = try await session.data(for: request) - return data + (data, response) = try await session.data(for: request) } catch { throw AWSSSOLoginError.network(error.localizedDescription) } + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw failure(status: http.statusCode, errorType: http.value(forHTTPHeaderField: "x-amzn-ErrorType"), data: data) + } + return data + } + + static func failure(status: Int, errorType: String?, data: Data) -> AWSSSOLoginError { + let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + let candidates: [String?] = [ + body?["error_description"] as? String, + body?["message"] as? String, + body?["Message"] as? String, + body?["error"] as? String, + errorType?.split(separator: ":").first.map(String.init) + ] + let detail = candidates.compactMap { $0 }.first { !$0.isEmpty } ?? "HTTP \(status)" + return .serverError(detail) } } diff --git a/apple/Plugins/TableProPluginKit/AWS/AWSSSOPortal.swift b/apple/Plugins/TableProPluginKit/AWS/AWSSSOPortal.swift new file mode 100644 index 0000000000..daeb0cbd3e --- /dev/null +++ b/apple/Plugins/TableProPluginKit/AWS/AWSSSOPortal.swift @@ -0,0 +1,178 @@ +import Foundation + +public struct AWSSSOAccount: Sendable, Hashable, Identifiable { + public let accountId: String + public let accountName: String? + public let emailAddress: String? + + public var id: String { accountId } + + public init(accountId: String, accountName: String? = nil, emailAddress: String? = nil) { + self.accountId = accountId + self.accountName = accountName + self.emailAddress = emailAddress + } +} + +public struct AWSSSOAccountRole: Sendable, Hashable, Identifiable { + public let accountId: String + public let roleName: String + + public var id: String { roleName } + + public init(accountId: String, roleName: String) { + self.accountId = accountId + self.roleName = roleName + } +} + +public enum AWSSSOPortal { + static let pageSize = 100 + + public static func listAccounts( + region: String, + accessToken: String, + session: URLSession = AWSHTTP.shared + ) async throws -> [AWSSSOAccount] { + let accounts = try await collectPages( + AccountsPage.self, + path: "/assignment/accounts", + query: [], + region: region, + accessToken: accessToken, + session: session + ) + return accounts.map { + AWSSSOAccount(accountId: $0.accountId, accountName: $0.accountName, emailAddress: $0.emailAddress) + } + } + + public static func listAccountRoles( + region: String, + accessToken: String, + accountId: String, + session: URLSession = AWSHTTP.shared + ) async throws -> [AWSSSOAccountRole] { + let roles = try await collectPages( + RolesPage.self, + path: "/assignment/roles", + query: [URLQueryItem(name: "account_id", value: accountId)], + region: region, + accessToken: accessToken, + session: session + ) + return roles.map { AWSSSOAccountRole(accountId: $0.accountId ?? accountId, roleName: $0.roleName) } + } + + private static func collectPages( + _ type: Page.Type, + path: String, + query: [URLQueryItem], + region: String, + accessToken: String, + session: URLSession + ) async throws -> [Page.Item] { + var items: [Page.Item] = [] + var nextToken: String? + var seenTokens = Set() + repeat { + var pageQuery = query + [URLQueryItem(name: "max_result", value: String(pageSize))] + if let nextToken { + pageQuery.append(URLQueryItem(name: "next_token", value: nextToken)) + } + let data = try await get( + path: path, + query: pageQuery, + region: region, + accessToken: accessToken, + session: session + ) + guard let page = try? JSONDecoder().decode(type, from: data) else { + throw AWSSSOLoginError.unexpectedResponse + } + items.append(contentsOf: page.items) + nextToken = page.nextToken.flatMap { $0.isEmpty ? nil : $0 } + // A token the portal already returned would page forever. + if let nextToken, !seenTokens.insert(nextToken).inserted { + throw AWSSSOLoginError.unexpectedResponse + } + } while nextToken != nil + return items + } + + private static func get( + path: String, + query: [URLQueryItem], + region: String, + accessToken: String, + session: URLSession + ) async throws -> Data { + let host = AWSPartition.host(service: "portal.sso", region: region) + guard var components = URLComponents(string: "https://\(host)\(path)") else { + throw AWSSSOLoginError.unexpectedResponse + } + // URLComponents leaves + and / unescaped in a query value, and the portal reads + as a space. + components.percentEncodedQueryItems = query.map { + URLQueryItem(name: $0.name, value: $0.value.map(AWSSigV4.uriEncode)) + } + guard let url = components.url else { + throw AWSSSOLoginError.unexpectedResponse + } + + 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 { + try Task.checkCancellation() + throw AWSSSOLoginError.network(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { + throw AWSSSOLoginError.unexpectedResponse + } + guard http.statusCode == 200 else { + throw AWSSSOLogin.failure( + status: http.statusCode, + errorType: http.value(forHTTPHeaderField: "x-amzn-ErrorType"), + data: data + ) + } + return data + } +} + +private protocol PortalPage: Decodable { + associatedtype Item + var items: [Item] { get } + var nextToken: String? { get } +} + +private struct AccountsPage: PortalPage { + struct Account: Decodable { + let accountId: String + let accountName: String? + let emailAddress: String? + } + + let accountList: [Account]? + let nextToken: String? + + var items: [Account] { accountList ?? [] } +} + +private struct RolesPage: PortalPage { + struct Role: Decodable { + let accountId: String? + let roleName: String + } + + let roleList: [Role]? + let nextToken: String? + + var items: [Role] { roleList ?? [] } +} diff --git a/apple/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift b/apple/Plugins/TableProPluginKit/AWS/RDSAuthTokenGenerator.swift similarity index 84% rename from apple/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift rename to apple/Plugins/TableProPluginKit/AWS/RDSAuthTokenGenerator.swift index 1378bf4b7d..a920fbdb3f 100644 --- a/apple/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift +++ b/apple/Plugins/TableProPluginKit/AWS/RDSAuthTokenGenerator.swift @@ -1,14 +1,4 @@ -// -// RDSAuthTokenGenerator.swift -// TablePro -// -// Builds an RDS IAM authentication token: a SigV4 presigned GET URL for the -// rds-db connect action, with the scheme stripped. The token is used as the -// database password and is valid for 15 minutes. -// - import Foundation -import TableProPluginKit enum RDSAuthTokenGenerator { private static let service = "rds-db" @@ -52,7 +42,9 @@ enum RDSAuthTokenGenerator { .map { "\($0.0)=\($0.1)" } .joined(separator: "&") - let canonicalHeaders = "host:\(host):\(port)\n" + // RDS checks the signature against its own endpoint name, which is lowercase. + let signedHost = host.lowercased() + let canonicalHeaders = "host:\(signedHost):\(port)\n" let signedHeaders = "host" let payloadHash = AWSSigV4.sha256Hex(Data()) @@ -80,7 +72,7 @@ enum RDSAuthTokenGenerator { ) let signature = AWSSigV4.hmacHex(key: signingKey, data: Data(stringToSign.utf8)) - let url = "https://\(host):\(port)/?\(canonicalQuery)&X-Amz-Signature=\(signature)" + let url = "https://\(signedHost):\(port)/?\(canonicalQuery)&X-Amz-Signature=\(signature)" return String(url.dropFirst("https://".count)) } } diff --git a/apple/TablePro/Core/Database/AWS/RDSEndpoint.swift b/apple/Plugins/TableProPluginKit/AWS/RDSEndpoint.swift similarity index 79% rename from apple/TablePro/Core/Database/AWS/RDSEndpoint.swift rename to apple/Plugins/TableProPluginKit/AWS/RDSEndpoint.swift index 7760ca2adc..ef574bbe44 100644 --- a/apple/TablePro/Core/Database/AWS/RDSEndpoint.swift +++ b/apple/Plugins/TableProPluginKit/AWS/RDSEndpoint.swift @@ -1,13 +1,8 @@ -// -// RDSEndpoint.swift -// TablePro -// - import Foundation enum RDSEndpoint { static func region(forHost host: String) -> String? { - let parts = host.split(separator: ".").map(String.init) + let parts = host.lowercased().split(separator: ".").map(String.init) guard let rdsIndex = parts.firstIndex(of: "rds"), rdsIndex > 0, parts.count > rdsIndex + 1, diff --git a/apple/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift b/apple/Plugins/TableProPluginKit/AWS/RDSSigningEndpoint.swift similarity index 83% rename from apple/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift rename to apple/Plugins/TableProPluginKit/AWS/RDSSigningEndpoint.swift index 2e37c74ca5..172b6cdd4c 100644 --- a/apple/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift +++ b/apple/Plugins/TableProPluginKit/AWS/RDSSigningEndpoint.swift @@ -1,22 +1,12 @@ -// -// RDSSigningEndpoint.swift -// TablePro -// -// An RDS IAM token is a SigV4 presigned URL whose signature covers the DB -// endpoint's host and port, and RDS validates it against its own endpoint. The -// socket the driver dials is not always that endpoint: a tunnel points it at -// loopback, and so does a port forward the user runs outside TablePro. This -// resolves the endpoint the token must be signed for. -// - import Foundation -import TableProPluginKit struct RDSSigningEndpoint: Equatable { let host: String let port: Int } +// RDS validates the token against its own endpoint, while a tunnel or a port forward run outside +// TablePro makes the driver dial loopback. This picks the endpoint the token is signed for. enum RDSSigningEndpointResolver { private static let loopbackHosts: Set = ["localhost", "localhost.localdomain", "::1", "[::1]", "0.0.0.0"] @@ -43,7 +33,7 @@ enum RDSSigningEndpointResolver { guard !isLoopback(host) else { throw AWSAuthError.rdsEndpointUnresolved(host: host) } - return RDSSigningEndpoint(host: host, port: port) + return RDSSigningEndpoint(host: host.lowercased(), port: port) } static func parse(_ value: String, defaultPort: Int) throws -> RDSSigningEndpoint { @@ -80,6 +70,6 @@ enum RDSSigningEndpointResolver { guard isHostname, !isLoopback(host) else { throw AWSAuthError.rdsEndpointInvalid(rawValue) } - return RDSSigningEndpoint(host: host, port: port) + return RDSSigningEndpoint(host: host.lowercased(), port: port) } } diff --git a/apple/TablePro/Core/Database/AWS/AWSIAMTokenSigner.swift b/apple/TablePro/Core/Database/AWS/AWSIAMTokenSigner.swift index bfcfe8a703..17ea18750f 100644 --- a/apple/TablePro/Core/Database/AWS/AWSIAMTokenSigner.swift +++ b/apple/TablePro/Core/Database/AWS/AWSIAMTokenSigner.swift @@ -1,75 +1,19 @@ -// -// AWSIAMTokenSigner.swift -// TablePro -// - import Foundation import TableProPluginKit -/// Signs the RDS or ElastiCache token an AWS IAM connection signs in with. It keeps its own copy -/// of the connection's settings, so a driver can ask for a new token without the main actor. +// Holds its own copy of the connection's settings, so a driver can ask for a new token without the main actor. struct AWSIAMTokenSigner: Sendable { - enum Target: Sendable, Equatable { - case elastiCache(host: String, tlsDisabled: Bool) - case rds( - configuredHost: String, - configuredPort: Int, - preTunnelHost: String?, - preTunnelPort: Int?, - defaultPort: Int - ) - } + typealias Target = AWSIAMTokenTarget let source: String - /// AWS checks the username inside the token against the one the connection signs in as. + // AWS checks the username inside the token against the one the connection signs in as. let username: String let fields: [String: String] let target: Target func token(deadline: ConnectionDeadline?) async throws -> String { let credentials = try await resolveCredentials(deadline: deadline) - switch target { - case .elastiCache(let host, let tlsDisabled): - guard let region = fields["awsRegion"].flatMap({ $0.isEmpty ? nil : $0 }) else { - throw AWSAuthError.regionUnknown(host: host) - } - guard !tlsDisabled 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: username, - credentials: credentials - ) - case .rds(let configuredHost, let configuredPort, let preTunnelHost, let preTunnelPort, let defaultPort): - let endpoint = try RDSSigningEndpointResolver.resolve( - configuredHost: configuredHost, - configuredPort: configuredPort, - preTunnelHost: preTunnelHost, - preTunnelPort: preTunnelPort, - override: fields["awsRDSEndpoint"], - defaultPort: defaultPort - ) - let explicitRegion = fields["awsRegion"].flatMap { $0.isEmpty ? nil : $0 } - guard let region = explicitRegion ?? RDSEndpoint.region(forHost: endpoint.host) else { - throw AWSAuthError.regionUnknown(host: endpoint.host) - } - return RDSAuthTokenGenerator.generateToken( - host: endpoint.host, - port: endpoint.port, - region: region, - username: username, - credentials: credentials - ) - } + return try target.token(username: username, fields: fields, credentials: credentials) } private func resolveCredentials(deadline: ConnectionDeadline?) async throws -> AWSCredentials { diff --git a/apple/TablePro/Core/Database/Google/CloudSQLIAMTokenCache.swift b/apple/TablePro/Core/Database/Google/CloudSQLIAMTokenCache.swift index 43b19bfb0d..5ede1ead2c 100644 --- a/apple/TablePro/Core/Database/Google/CloudSQLIAMTokenCache.swift +++ b/apple/TablePro/Core/Database/Google/CloudSQLIAMTokenCache.swift @@ -1,30 +1,19 @@ -// -// CloudSQLIAMTokenCache.swift -// TablePro -// - import Foundation import TableProGoogleCloud import TableProPluginKit -/// What a Cloud SQL IAM connection signs in with, copied from the connection so a token can be -/// minted off the main actor. +// Copied from the connection so a token can be minted off the main actor. struct CloudSQLIAMLogin: Sendable, Equatable { let connectionId: UUID let source: GoogleCredentialSource } -/// One token provider per connection, so the connect, each metadata connection, a reconnect and a -/// backup share a token until five minutes before it expires. +// One provider per connection, so the connect, each metadata connection, a reconnect and a backup +// share a token until five minutes before it expires. actor CloudSQLIAMTokenCache { static let shared = CloudSQLIAMTokenCache() - /// The scope `gcloud sql generate-login-token` and the Cloud SQL connectors ask for. It allows a - /// database sign-in and nothing else, so a token that leaks cannot call another Google API. - static let scopes = ["https://www.googleapis.com/auth/sqlservice.login"] - - /// The credential bytes a provider was built from. An edited key or a new gcloud login reads - /// different bytes, which replaces the provider and the token it holds. + // An edited key or a new gcloud login reads different bytes, which replaces the provider and its token. private struct Fingerprint: Equatable { let source: GoogleCredentialSource let documents: [String: Data] @@ -66,7 +55,7 @@ actor CloudSQLIAMTokenCache { do { credentials = try GoogleTokenProviders.credentials( from: login.source, - scopes: Self.scopes, + scopes: GoogleCloudSQLLogin.scopes, restrictsUserLogin: true, readFile: { path in let contents = readFile(path) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 24c5ec678c..cbf357f20c 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -68928,6 +68928,8 @@ } }, "ElastiCache IAM authentication requires TLS. Enable SSL in the connection's SSL settings." : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -70763,6 +70765,8 @@ } }, "Enter the ElastiCache cache name (replication group ID) to use IAM authentication." : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { diff --git a/apple/TableProMobile/TableProMobile/CBridges/CMariaDB/CMariaDB.h b/apple/TableProMobile/TableProMobile/CBridges/CMariaDB/CMariaDB.h index e201da51ca..cb8ff18715 100644 --- a/apple/TableProMobile/TableProMobile/CBridges/CMariaDB/CMariaDB.h +++ b/apple/TableProMobile/TableProMobile/CBridges/CMariaDB/CMariaDB.h @@ -2,5 +2,28 @@ #define CMariaDB_h #include +#include +#include +// ma_pvio.h stands MARIADB_TLS in for void when HAVE_TLS is unset; ma_tls.h declares the struct. +#undef MARIADB_TLS +#include + +// Clears the status the way the library's own skip does: a status left set fails the +// sign-in after the server accepts it. +static int tablepro_mysql_accept_any_certificate(MARIADB_TLS *ctls, unsigned int flags) { + (void)flags; + if (ctls != NULL && ctls->pvio != NULL && ctls->pvio->mysql != NULL) { + ctls->pvio->mysql->net.tls_verify_status = 0; + } + return 0; +} + +// MYSQL_OPT_SSL_ENFORCE alone falls back to plaintext. A required verification refuses a server +// without TLS before any credential is sent, and the callback then skips the certificate check. +static inline void tablepro_mysql_require_tls_without_verification(MYSQL *mysql) { + my_bool verify = 1; + mysql_optionsv(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &verify); + mysql_optionsv(mysql, MARIADB_OPT_TLS_VERIFICATION_CALLBACK, tablepro_mysql_accept_any_certificate); +} #endif diff --git a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index fa4fb42f78..a054749b48 100644 --- a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -193,8 +193,8 @@ final class ConnectionCoordinator { await connectFresh(token: token) } - /// `allowSignIn` is false on the retry that follows a sign-in, so a connection that keeps - /// failing cannot put the prompt up again and again. + // `allowSignIn` is false on the retry that follows a sign-in, so a connection that keeps + // failing cannot put the prompt up again and again. private func connectFresh(token: UUID, allowSignIn: Bool = true) async { IOSAnalyticsProvider.shared.markConnectionAttempted() @@ -214,23 +214,31 @@ final class ConnectionCoordinator { navigateToPendingTable() } catch { guard attemptToken == token else { return } + var failure = error // A sign-in that expired is recoverable, so offer it once and retry rather than // leaving the user on an error screen whose only button repeats the same failure. if allowSignIn, - EntraSignIn.needsSignIn(error), - await EntraSignIn.offer(fields: connection.additionalFields, prompts: prompts) { - guard attemptToken == token else { return } - await connectFresh(token: token, allowSignIn: false) - return + let method = SignInOffer.method(for: error, connection: connection, secureStore: appState.secureStore) { + switch await SignInOffer.offer(method, prompts: prompts) { + case .signedIn: + guard attemptToken == token else { return } + await connectFresh(token: token, allowSignIn: false) + return + case .failed(let signInError): + failure = signInError + case .declined: + break + } } guard attemptToken == token else { return } let context = ErrorContext( operation: "connect", databaseType: connection.type, host: connection.host, - sshEnabled: connection.sshEnabled + sshEnabled: connection.sshEnabled, + signsInWithIAM: connection.iamSignIn != nil ) - phase = .error(ErrorClassifier.classify(error, context: context)) + phase = .error(ErrorClassifier.classify(failure, context: context)) } } @@ -304,7 +312,8 @@ final class ConnectionCoordinator { operation: "reconnect", databaseType: connection.type, host: connection.host, - sshEnabled: connection.sshEnabled + sshEnabled: connection.sshEnabled, + signsInWithIAM: connection.iamSignIn != nil ) phase = .error(ErrorClassifier.classify(error, context: context)) self.session = nil @@ -560,7 +569,8 @@ final class ConnectionCoordinator { operation: "switchDatabase", databaseType: connection.type, host: connection.host, - sshEnabled: connection.sshEnabled + sshEnabled: connection.sshEnabled, + signsInWithIAM: connection.iamSignIn != nil ) phase = .error(ErrorClassifier.classify(error, context: context)) self.session = nil diff --git a/apple/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift b/apple/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift index 2723a30329..f3478bca27 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift @@ -8,6 +8,8 @@ nonisolated struct DriverSSLConfiguration: Equatable, Sendable { let caCertificatePath: String? let clientCertificatePath: String? let clientKeyPath: String? + // The name the certificate must carry when the dialed host is a tunnel's loopback. + var serverName: String? static let disabled = DriverSSLConfiguration(mode: .disable) @@ -20,12 +22,14 @@ nonisolated struct DriverSSLConfiguration: Equatable, Sendable { mode: SSLConfiguration.SSLMode, caCertificatePath: String? = nil, clientCertificatePath: String? = nil, - clientKeyPath: String? = nil + clientKeyPath: String? = nil, + serverName: String? = nil ) { self.mode = mode self.caCertificatePath = caCertificatePath self.clientCertificatePath = clientCertificatePath self.clientKeyPath = clientKeyPath + self.serverName = serverName } init(sslEnabled: Bool, configuration: SSLConfiguration?) { @@ -47,7 +51,8 @@ nonisolated struct DriverSSLConfiguration: Equatable, Sendable { mode: mode, caCertificatePath: materialized.caCertificatePath ?? caCertificatePath, clientCertificatePath: materialized.clientCertificatePath ?? clientCertificatePath, - clientKeyPath: materialized.clientKeyPath ?? clientKeyPath + clientKeyPath: materialized.clientKeyPath ?? clientKeyPath, + serverName: serverName ) } diff --git a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index fca3ef02c7..4d5cf15cbc 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -19,6 +19,7 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { let ssl: DriverSSLConfiguration let databaseType: DatabaseType private let connectionEncoding: MySQLConnectionEncoding + let signsInWithToken: Bool var supportsSchemas: Bool { false } var currentSchema: String? { nil } @@ -39,7 +40,8 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { database: String, ssl: DriverSSLConfiguration = .disabled, databaseType: DatabaseType = .mysql, - connectionEncoding: MySQLConnectionEncoding = .utf8 + connectionEncoding: MySQLConnectionEncoding = .utf8, + signsInWithToken: Bool = false ) { self.host = host self.port = port @@ -49,6 +51,7 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { self.ssl = ssl self.databaseType = databaseType self.connectionEncoding = connectionEncoding + self.signsInWithToken = signsInWithToken } // MARK: - Connection @@ -92,7 +95,8 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { try await LocalNetworkPermission.shared.ensureAccess(for: host) try await actor.connect( host: host, port: port, user: user, password: password, database: database, - ssl: ssl, encoding: connectionEncoding, databaseType: databaseType + ssl: ssl, encoding: connectionEncoding, databaseType: databaseType, + signsInWithToken: signsInWithToken ) serverVersion = await actor.serverVersion() for statement in Self.sessionSetupStatements(for: databaseType) { @@ -329,6 +333,20 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { } } +nonisolated enum MySQLTokenSignIn { + // A token session refuses a server without TLS before the token is sent; this names that refusal. + static func connectFailure( + signsInWithToken: Bool, + errorCode: UInt32, + serverCapabilities: UInt + ) -> IAMSignInError? { + guard signsInWithToken, + errorCode == UInt32(CR_SSL_CONNECTION_ERROR), + serverCapabilities & UInt(CLIENT_SSL) == 0 else { return nil } + return .encryptionNotEstablished + } +} + nonisolated extension MySQLDriver { static func showFullColumnsStatement(table: String, database: String?) -> String { "SHOW FULL COLUMNS FROM \(qualifiedTable(table, database: database))" @@ -439,7 +457,8 @@ private actor MySQLActor { func connect( host: String, port: Int, user: String, password: String, database: String, - ssl: DriverSSLConfiguration, encoding: MySQLConnectionEncoding, databaseType: DatabaseType + ssl: DriverSSLConfiguration, encoding: MySQLConnectionEncoding, databaseType: DatabaseType, + signsInWithToken: Bool ) async throws { self.encoding = encoding errorLanguage = nil @@ -478,6 +497,14 @@ private actor MySQLActor { if let clientKeyPath = ssl.existingClientKeyPath { _ = clientKeyPath.withCString { mysql_options(handle, MYSQL_OPT_SSL_KEY, $0) } } + if signsInWithToken { + // A no-op in Connector/C 3.4, which answers a cleartext request anyway; set to match the Mac. + var enableCleartext: my_bool = 1 + mysql_options(handle, MYSQL_ENABLE_CLEARTEXT_PLUGIN, &enableCleartext) + if !ssl.verifiesCertificate { + tablepro_mysql_require_tls_without_verification(handle) + } + } guard let portU32 = UInt32(exactly: port), (1...65_535).contains(port) else { mysql_close(handle) @@ -504,9 +531,14 @@ private actor MySQLActor { ) guard connected else { + let tokenFailure = MySQLTokenSignIn.connectFailure( + signsInWithToken: signsInWithToken, + errorCode: mysql_errno(handle), + serverCapabilities: handle.pointee.server_capabilities + ) let msg = message(from: handle) mysql_close(handle) - throw MySQLError.connectionFailed(msg) + throw tokenFailure ?? MySQLError.connectionFailed(msg) } guard MariaDBCharacterSet.establishSession(on: handle, encoding: encoding) else { diff --git a/apple/TableProMobile/TableProMobile/Drivers/PostgreSQLConnectionString.swift b/apple/TableProMobile/TableProMobile/Drivers/PostgreSQLConnectionString.swift index 38101239c5..958712522c 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/PostgreSQLConnectionString.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/PostgreSQLConnectionString.swift @@ -12,8 +12,7 @@ nonisolated enum PostgreSQLConnectionString { password: String, ssl: DriverSSLConfiguration ) -> String { - var parameters: [(String, String)] = [ - ("host", host), + var parameters: [(String, String)] = endpoint(dialing: host, serverName: ssl.serverName) + [ ("port", String(port)), ("dbname", database), ("user", user), @@ -38,6 +37,20 @@ nonisolated enum PostgreSQLConnectionString { .joined(separator: " ") } + // libpq checks the certificate and sends SNI for `host`, and dials `hostaddr` without a lookup. + private static func endpoint(dialing host: String, serverName: String?) -> [(String, String)] { + guard let serverName, !serverName.isEmpty, serverName != host, isIPAddress(host) else { + return [("host", host)] + } + return [("host", serverName), ("hostaddr", host)] + } + + private static func isIPAddress(_ host: String) -> Bool { + var ipv4 = in_addr() + var ipv6 = in6_addr() + return inet_pton(AF_INET, host, &ipv4) == 1 || inet_pton(AF_INET6, host, &ipv6) == 1 + } + static func escape(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "'", with: "\\'") diff --git a/apple/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index 638feaddc0..6562e6e3c5 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -472,7 +472,7 @@ nonisolated extension RedisError { nonisolated extension RedisTLSOptions { init(ssl: DriverSSLConfiguration, host: String) { self.init( - host: host, + host: ssl.serverName ?? host, verifiesCertificate: ssl.verifiesCertificate, verifiesHostname: ssl.verifiesHostname, caCertificatePath: ssl.existingCACertificatePath, diff --git a/apple/TableProMobile/TableProMobile/Helpers/AppError+IAM.swift b/apple/TableProMobile/TableProMobile/Helpers/AppError+IAM.swift new file mode 100644 index 0000000000..5a2a5a3cea --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/AppError+IAM.swift @@ -0,0 +1,359 @@ +import Foundation +import TableProGoogleCloud +import TableProPluginKit + +// Sign-in errors are read by their case: their messages embed the user's host, profile or start +// URL, which the substring rules would file as an SSH or password failure. +nonisolated extension ErrorClassifier { + static func signInFailure(_ error: Error, context: ErrorContext) -> AppError? { + // Test Connection's result has no Retry button, so it gets no recovery that names one. + let retries = context.operation != "testConnection" + switch error { + case let iamError as IAMSignInError: + return iamFailure(iamError, retries: retries) + case let awsError as AWSAuthError: + return awsFailure(awsError) + case let ssoError as AWSSSOError: + return ssoFailure(ssoError, retries: retries) + case let loginError as AWSSSOLoginError: + return ssoLoginFailure(loginError, retries: retries) + case let googleError as GoogleAuthError: + return googleFailure(googleError) + case let entraError as EntraOAuthError: + return entraFailure(entraError, retries: retries) + default: + return nil + } + } + + private static func iamFailure(_ error: IAMSignInError, retries: Bool) -> AppError { + switch error { + case .accessKeyMissing: + return accessKeyNeeded(error) + case .profileNeedsDeviceKey: + return awsProfileOnMac(error, title: String(localized: "AWS Profile Is on the Mac")) + case .applicationDefaultNeedsDeviceKey: + return applicationDefaultOnMac(error, message: error.localizedDescription) + case .serviceAccountKeyMissing: + return failure( + error, + .config, + title: String(localized: "Service Account Key Needed"), + recovery: String(localized: "Paste the service account JSON key in this connection's Authentication section."), + opensEditor: true + ) + case .serviceAccountKeyIsMacFile: + return failure( + error, + .config, + title: String(localized: "Service Account Key Is on the Mac"), + recovery: String(localized: "Paste the JSON key on this device in the connection's Authentication section."), + opensEditor: true + ) + case .ssoNotSetUp: + return ssoNotSetUp(error) + case .ssoSignInRequired: + return ssoSignInRequired(error, message: error.localizedDescription, retries: retries) + case .encryptionNotEstablished: + return failure( + error, + .config, + title: String(localized: "Encryption Not Established"), + recovery: String(localized: "Check that the server accepts TLS connections.") + ) + } + } + + private static func awsFailure(_ error: AWSAuthError) -> AppError { + switch error { + case .missingAccessKey: + return accessKeyNeeded(error) + case .regionUnknown: + return failure( + error, + .config, + title: String(localized: "AWS Region Needed"), + recovery: String(localized: "Set the AWS Region in this connection's Authentication section."), + opensEditor: true + ) + case .rdsEndpointUnresolved: + return failure( + error, + .config, + title: String(localized: "RDS Endpoint Needed"), + recovery: String(localized: "Enter the RDS Endpoint in this connection's Authentication section."), + opensEditor: true + ) + case .rdsEndpointInvalid: + return failure( + error, + .config, + title: String(localized: "RDS Endpoint Invalid"), + recovery: String(localized: "Enter the RDS Endpoint in this connection's Authentication section."), + opensEditor: true + ) + case .missingConfiguration: + return failure( + error, + .config, + title: String(localized: "AWS IAM Setup Incomplete"), + recovery: String(localized: "Check this connection's Authentication section."), + opensEditor: true + ) + case .assumeRoleFailed: + return failure( + error, + .auth, + title: String(localized: "AWS Sign-In Failed"), + recovery: String(localized: "Check that these AWS credentials are allowed to assume the role.") + ) + case .credentialsFileUnreadable, .profileIncomplete, .credentialProcessInvalid, + .credentialProcessLaunchFailed, .credentialProcessFailed, .credentialProcessBadOutput, + .credentialProcessUnsupportedVersion, .credentialProcessUnsupportedOnPlatform, + .assumeRoleMissingSource, .assumeRoleChainTooDeep, .mfaUnsupported, + .webIdentityUnsupported, .credentialSourceUnsupported: + return awsProfileOnMac(error, title: String(localized: "AWS Profile Unavailable")) + } + } + + // The PluginKit messages for an expired session say to run the AWS CLI, which a phone does not have. + private static func ssoFailure(_ error: AWSSSOError, retries: Bool) -> AppError { + switch error { + case .configReadFailed, .profileNotFound, .profileMissingFields, .sessionNotFound, + .sessionMissingFields, .profileMissingUrlOrRegion: + return ssoNotSetUp(error) + case .tokenCacheNotFound, .tokenCacheMalformed, .tokenExpired, .sessionUnauthorized: + return ssoSignInRequired( + error, + message: String(localized: "The IAM Identity Center sign-in on this device has expired."), + retries: retries + ) + case .credentialsAlreadyExpired: + return failure( + error, + .system, + title: String(localized: "AWS SSO Sign-In Failed"), + message: String(localized: "IAM Identity Center returned role credentials that had already expired."), + recovery: String(localized: "Check the date and time on this device.") + ) + case .roleNotAccessible: + return failure( + error, + .permission, + title: String(localized: "Role Not Accessible"), + recovery: String(localized: "Pick another account or role in this connection's Authentication section."), + opensEditor: true + ) + case .networkFailure: + return unreachable(error, title: String(localized: "Can't Reach IAM Identity Center")) + case .urlBuildFailed, .invalidResponse, .portalError, .responseDecodeFailed: + return failure( + error, + .system, + title: String(localized: "AWS SSO Sign-In Failed"), + recovery: String(localized: "Try again in a moment.") + ) + } + } + + private static func ssoLoginFailure(_ error: AWSSSOLoginError, retries: Bool) -> AppError { + switch error { + case .network: + return unreachable(error, title: String(localized: "Can't Reach IAM Identity Center")) + case .unexpectedResponse, .serverError: + return failure( + error, + .config, + title: String(localized: "AWS SSO Sign-In Failed"), + recovery: String(localized: "Check the start URL and SSO Region in this connection's Authentication section."), + opensEditor: true + ) + case .authorizationTimedOut: + return signInTimedOut(error, retries: retries) + case .accessDenied: + return signInDenied(error, retries: retries) + } + } + + private static func googleFailure(_ error: GoogleAuthError) -> AppError { + let message = GoogleAuthErrorMessages.message(for: error) + switch error { + case .credentialNotJSON, .credentialIsPEM, .credentialFileUnreadable, .credentialMissingField, + .malformedPrivateKey, .unsupportedCredentialType, .untrustedEndpoint: + return failure( + error, + .config, + title: String(localized: "Service Account Key Invalid"), + message: message, + recovery: String(localized: "Paste the whole service account JSON key in this connection's Authentication section."), + opensEditor: true + ) + case .applicationDefaultCredentialsNotFound: + return applicationDefaultOnMac(error, message: message) + case .tokenRequestRejected: + return failure( + error, + .auth, + title: String(localized: "Google Sign-In Failed"), + message: message, + recovery: String(localized: "Check that the service account key is still active, then paste it again in this connection's Authentication section."), + opensEditor: true + ) + case .transport: + return unreachable(error, title: String(localized: "Can't Reach Google"), message: message) + case .invalidTokenResponse, .signingFailed: + return failure( + error, + .system, + title: String(localized: "Google Sign-In Failed"), + message: message, + recovery: String(localized: "Try again in a moment.") + ) + case .signInRequired, .oauthTimedOut, .oauthDenied, .oauthStateMismatch, .oauthCancelled: + return failure( + error, + .auth, + title: String(localized: "Google Sign-In Failed"), + message: message, + recovery: nil + ) + } + } + + private static func entraFailure(_ error: EntraOAuthError, retries: Bool) -> AppError { + switch error { + case .notConfigured: + return failure( + error, + .config, + title: String(localized: "Microsoft Entra ID Not Set Up"), + recovery: String(localized: "Set the application (client) ID for this connection on the Mac.") + ) + case .signInRequired, .interactionRequired, .refreshRejected: + return failure( + error, + .auth, + title: String(localized: "Microsoft Entra ID Sign-In Required"), + recovery: retries ? String(localized: "Tap Retry to sign in again.") : nil + ) + case .network: + return unreachable(error, title: String(localized: "Can't Reach Microsoft Entra ID")) + case .unexpectedResponse, .serverError: + return failure( + error, + .system, + title: String(localized: "Microsoft Entra ID Sign-In Failed"), + recovery: String(localized: "Try again in a moment.") + ) + case .authorizationTimedOut: + return signInTimedOut(error, retries: retries) + case .accessDenied: + return signInDenied(error, retries: retries) + } + } + + private static func accessKeyNeeded(_ error: Error) -> AppError { + failure( + error, + .config, + title: String(localized: "AWS Access Key Needed"), + recovery: String( + localized: "Enter the access key in this connection's Authentication section, or turn on Sync Passwords on the Mac and save the connection there again." + ), + opensEditor: true + ) + } + + private static func awsProfileOnMac(_ error: Error, title: String) -> AppError { + failure( + error, + .config, + title: title, + recovery: String( + localized: "Add an access key for this device in the connection's Authentication section. The connection on the Mac does not change." + ), + opensEditor: true + ) + } + + private static func applicationDefaultOnMac(_ error: Error, message: String) -> AppError { + failure( + error, + .config, + title: String(localized: "Google Sign-In Is on the Mac"), + message: message, + recovery: String( + localized: "Add a service account key for this device in the connection's Authentication section. The connection on the Mac does not change." + ), + opensEditor: true + ) + } + + private static func ssoNotSetUp(_ error: Error) -> AppError { + failure( + error, + .config, + title: String(localized: "IAM Identity Center Not Set Up"), + recovery: String(localized: "Enter the start URL and SSO Region in this connection's Authentication section, then sign in."), + opensEditor: true + ) + } + + private static func ssoSignInRequired(_ error: Error, message: String, retries: Bool) -> AppError { + failure( + error, + .auth, + title: String(localized: "AWS SSO Sign-In Required"), + message: message, + recovery: retries ? String(localized: "Tap Retry to sign in to IAM Identity Center.") : nil + ) + } + + private static func signInTimedOut(_ error: Error, retries: Bool) -> AppError { + failure( + error, + .auth, + title: String(localized: "Sign-In Timed Out"), + recovery: retries + ? String(localized: "Tap Retry, then approve the sign-in in the browser before the code expires.") + : nil + ) + } + + private static func signInDenied(_ error: Error, retries: Bool) -> AppError { + failure( + error, + .auth, + title: String(localized: "Sign-In Denied"), + recovery: retries ? String(localized: "Tap Retry to sign in again.") : nil + ) + } + + private static func unreachable(_ error: Error, title: String, message: String? = nil) -> AppError { + failure( + error, + .network, + title: title, + message: message, + recovery: String(localized: "Check your network connection and try again.") + ) + } + + private static func failure( + _ error: Error, + _ category: AppErrorCategory, + title: String, + message: String? = nil, + recovery: String?, + opensEditor: Bool = false + ) -> AppError { + AppError( + category: category, + title: title, + message: message ?? error.localizedDescription, + recovery: recovery, + underlying: error, + opensEditor: opensEditor + ) + } +} diff --git a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift index a266c84950..d4de6e734e 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -25,9 +25,10 @@ nonisolated struct AppError: LocalizedError, Sendable { let message: String let recovery: String? let underlying: Error? - /// Set when an Oracle listener rejected the connect identifier and named the - /// mode it was given, so the form can offer the other mode in one tap. + // Set when an Oracle listener rejected the connect identifier and named the + // mode it was given, so the form can offer the other mode in one tap. let suggestedOracleMode: OracleConnectionOptions.IdentifierMode? + let opensEditor: Bool init( category: AppErrorCategory, @@ -35,7 +36,8 @@ nonisolated struct AppError: LocalizedError, Sendable { message: String, recovery: String?, underlying: Error?, - suggestedOracleMode: OracleConnectionOptions.IdentifierMode? = nil + suggestedOracleMode: OracleConnectionOptions.IdentifierMode? = nil, + opensEditor: Bool = false ) { self.category = category self.title = title @@ -43,6 +45,7 @@ nonisolated struct AppError: LocalizedError, Sendable { self.recovery = recovery self.underlying = underlying self.suggestedOracleMode = suggestedOracleMode + self.opensEditor = opensEditor } var errorDescription: String? { message } @@ -67,12 +70,20 @@ nonisolated struct ErrorContext: Sendable { let databaseType: DatabaseType? let host: String? let sshEnabled: Bool + let signsInWithIAM: Bool - init(operation: String, databaseType: DatabaseType? = nil, host: String? = nil, sshEnabled: Bool = false) { + init( + operation: String, + databaseType: DatabaseType? = nil, + host: String? = nil, + sshEnabled: Bool = false, + signsInWithIAM: Bool = false + ) { self.operation = operation self.databaseType = databaseType self.host = host self.sshEnabled = sshEnabled + self.signsInWithIAM = signsInWithIAM } } @@ -126,6 +137,10 @@ nonisolated enum ErrorClassifier { return connectionFailure(connectionError) } + if let signInError = signInFailure(error, context: context) { + return signInError + } + if let tunnelError = error as? SSHTunnelError, let classified = sshTunnelFailure(tunnelError) { return classified } @@ -347,11 +362,14 @@ nonisolated enum ErrorClassifier { private static func auth(_ error: Error, context: ErrorContext) -> AppError { let dbName = context.databaseType?.rawValue ?? String(localized: "Database") + let recovery = context.signsInWithIAM + ? String(localized: "This connection signs in with IAM, not a password. Check that the database user is set up for IAM sign-in.") + : String(format: String(localized: "Check your %@ username and password."), dbName) return AppError( category: .auth, title: String(localized: "Authentication Failed"), message: error.localizedDescription, - recovery: String(format: String(localized: "Check your %@ username and password."), dbName), + recovery: recovery, underlying: error ) } diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift index 59e88761de..8651c33a7b 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift @@ -27,6 +27,15 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { var networkEncryption: OracleConnectionOptions.NetworkEncryption } + nonisolated struct IAMOptions: Equatable, Sendable { + var method: IAMSignIn? + var region: String + var accessKeyId: String + var profileName: String + var rdsEndpoint: String + var replicationGroupId: String + } + var name: String var type: DatabaseType var host: String @@ -40,6 +49,7 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { var sslMode: SSLConfiguration.SSLMode? var sshTunnel: SSHTunnel? var oracle: OracleOptions? + var iam: IAMOptions? static func tagIds(selecting tagId: UUID?, over existing: [UUID]) -> [UUID] { let others = existing.dropFirst().filter { $0 != tagId } @@ -69,6 +79,7 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { if changed(\.sslMode) { applySSLMode(to: &connection) } if changed(\.sshTunnel) { applySSHTunnel(to: &connection, changedSince: opening?.sshTunnel) } if changed(\.oracle) { applyOracleOptions(to: &connection, changedSince: opening?.oracle) } + if changed(\.iam) { applyIAMOptions(to: &connection, changedSince: opening?.iam) } return connection } @@ -184,4 +195,28 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { connection.additionalFields[Key.networkEncryption] = oracle.networkEncryption.rawValue } } + + private func applyIAMOptions(to connection: inout DatabaseConnection, changedSince opening: IAMOptions?) { + guard let iam else { return } + func changed(_ field: KeyPath) -> Bool { + Self.differs(field, from: opening, to: iam) + } + func write(_ value: String, to key: String) { + connection.additionalFields[key] = value.isEmpty ? nil : value + } + + typealias Field = IAMSignIn.Field + if changed(\.method) { + if let method = iam.method { + connection.additionalFields[Field.method] = method.pickerValue + } else if connection.additionalFields[Field.method] != nil { + connection.additionalFields[Field.method] = IAMSignIn.passwordPickerValue + } + } + if changed(\.region) { write(iam.region, to: Field.region) } + if changed(\.accessKeyId) { write(iam.accessKeyId, to: Field.accessKeyId) } + if changed(\.profileName) { write(iam.profileName, to: Field.profileName) } + if changed(\.rdsEndpoint) { write(iam.rdsEndpoint, to: Field.rdsEndpoint) } + if changed(\.replicationGroupId) { write(iam.replicationGroupId, to: Field.replicationGroupId) } + } } diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 275e97f59b..49efc966bc 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -1384,6 +1384,12 @@ } } } + }, + "Access Key for This Device" : { + + }, + "Access Key ID" : { + }, "Account" : { "extractionState" : "manual", @@ -1591,6 +1597,12 @@ } } } + }, + "Add a service account key for this device in the connection's Authentication section. The connection on the Mac does not change." : { + + }, + "Add an access key for this device in the connection's Authentication section. The connection on the Mac does not change." : { + }, "Add Connection" : { "localizations" : { @@ -2324,6 +2336,13 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "Application Default credentials are files on the Mac. A key added here signs in on this device only. The Mac's connection does not change." : { + + }, + "Application default credentials not found. Run gcloud auth application-default login." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Apply" : { "localizations" : { "ko" : { @@ -2594,6 +2613,9 @@ } } } + }, + "Authentication" : { + }, "Authentication Failed" : { "localizations" : { @@ -2706,6 +2728,42 @@ } } } + }, + "AWS Access Key Needed" : { + + }, + "AWS IAM (Access Key)" : { + + }, + "AWS IAM (Profile)" : { + + }, + "AWS IAM (SSO)" : { + + }, + "AWS IAM Setup Incomplete" : { + + }, + "AWS Profile Is on the Mac" : { + + }, + "AWS Profile Unavailable" : { + + }, + "AWS Region" : { + + }, + "AWS Region Needed" : { + + }, + "AWS Sign-In Failed" : { + + }, + "AWS SSO Sign-In Failed" : { + + }, + "AWS SSO Sign-In Required" : { + }, "Available in all connections" : { "localizations" : { @@ -2975,6 +3033,9 @@ "Cable" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Cache Name / Replication Group ID" : { + }, "Cake" : { "extractionState" : "manual", @@ -3007,6 +3068,19 @@ } } } + }, + "Can't Reach Google" : { + + }, + "Can't reach Google's sign-in service: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Can't Reach IAM Identity Center" : { + + }, + "Can't Reach Microsoft Entra ID" : { + }, "Cancel" : { "localizations" : { @@ -3203,6 +3277,24 @@ "Chat" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Check that the server accepts TLS connections." : { + + }, + "Check that the service account key is still active, then paste it again in this connection's Authentication section." : { + + }, + "Check that these AWS credentials are allowed to assume the role." : { + + }, + "Check the date and time on this device." : { + + }, + "Check the start URL and SSO Region in this connection's Authentication section." : { + + }, + "Check this connection's Authentication section." : { + }, "Check this jump host's address, port, username and key under Jump Hosts." : { @@ -3290,6 +3382,9 @@ } } } + }, + "Check your network connection and try again." : { + }, "Check your query and try again." : { "localizations" : { @@ -4037,6 +4132,9 @@ } } } + }, + "Confirm the code %@ in the browser to finish signing in." : { + }, "Confirm Writes" : { "extractionState" : "manual", @@ -5387,6 +5485,10 @@ } } }, + "Credentials of type %@ are not supported." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Credit Card" : { "extractionState" : "manual", "generatesSymbol" : false @@ -6814,6 +6916,9 @@ } } } + }, + "Encryption Not Established" : { + }, "ends with" : { "localizations" : { @@ -6984,6 +7089,9 @@ } } } + }, + "Enter the access key in this connection's Authentication section, or turn on Sync Passwords on the Mac and save the connection there again." : { + }, "Enter the code %@ in the browser to finish signing in. It is on your clipboard." : { "localizations" : { @@ -7068,6 +7176,12 @@ } } } + }, + "Enter the RDS Endpoint in this connection's Authentication section." : { + + }, + "Enter the start URL and SSO Region in this connection's Authentication section, then sign in." : { + }, "Envelope" : { "extractionState" : "manual", @@ -7843,6 +7957,9 @@ } } } + }, + "File on the Mac" : { + }, "Fill in at least one column. This database cannot insert a row made only of defaults." : { "localizations" : { @@ -8313,6 +8430,54 @@ } } }, + "Google Cloud IAM (Application Default)" : { + + }, + "Google Cloud IAM (Service Account)" : { + + }, + "Google no longer accepts these credentials. Sign in again, or renew the key or the gcloud login." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Google rejected the OAuth client ID or secret." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Google rejected the token request (%@)." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Google rejected the token request (HTTP %lld)." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Google returned a token response that can't be read." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Google Sign-In Failed" : { + + }, + "Google Sign-In Is on the Mac" : { + + }, + "Google sign-in returned an unexpected response. Try again." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Google sign-in timed out. Try again." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Google sign-in was cancelled." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Google sign-in was denied: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Graduation Cap" : { "extractionState" : "manual", "generatesSymbol" : false @@ -8810,6 +8975,15 @@ "House" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "IAM Identity Center" : { + + }, + "IAM Identity Center Not Set Up" : { + + }, + "IAM Identity Center returned role credentials that had already expired." : { + }, "iCloud" : { "localizations" : { @@ -10107,6 +10281,9 @@ "Key" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Key for This Device" : { + }, "Key Not Found" : { @@ -10147,6 +10324,9 @@ }, "Keys of type %@ cannot be opened here." : { + }, + "Keys sync to your other devices when Sync Passwords is on." : { + }, "Keyword" : { "localizations" : { @@ -11017,6 +11197,15 @@ "Mesh" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Method" : { + + }, + "Microsoft Entra ID Not Set Up" : { + + }, + "Microsoft Entra ID Sign-In Failed" : { + }, "Microsoft Entra ID Sign-In Required" : { "localizations" : { @@ -12522,6 +12711,9 @@ }, "Not Signed In to iCloud" : { + }, + "Not valid" : { + }, "Not valid JSON." : { @@ -13740,6 +13932,21 @@ } } } + }, + "Paste the contents of the JSON key. A file path cannot be read on this device." : { + + }, + "Paste the JSON key on this device in the connection's Authentication section." : { + + }, + "Paste the service account JSON key in this connection's Authentication section." : { + + }, + "Paste the whole JSON key from Google Cloud, including its braces." : { + + }, + "Paste the whole JSON key, not the private key alone." : { + }, "Paste the whole PEM block, including its BEGIN and END lines." : { "localizations" : { @@ -13769,6 +13976,13 @@ } } }, + "Paste the whole service account JSON key in this connection's Authentication section." : { + + }, + "Paste the whole service account JSON key or its file path, not the private key alone." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Path" : { "localizations" : { "ko" : { @@ -13823,6 +14037,9 @@ "Photo" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Pick another account or role in this connection's Authentication section." : { + }, "Pie Chart" : { "extractionState" : "manual", @@ -14181,6 +14398,9 @@ "Processor" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Profile Name" : { + }, "protocol negotiation" : { "extractionState" : "manual", @@ -14559,6 +14779,15 @@ }, "Ran %d statements" : { + }, + "RDS Endpoint (optional)" : { + + }, + "RDS Endpoint Invalid" : { + + }, + "RDS Endpoint Needed" : { + }, "Read it with a command in Query." : { @@ -15456,6 +15685,9 @@ } } } + }, + "Role Not Accessible" : { + }, "Roll Back" : { @@ -15980,6 +16212,9 @@ } } } + }, + "Saved" : { + }, "Save Query" : { @@ -16248,6 +16483,9 @@ } } } + }, + "Secret Access Key" : { + }, "Section" : { "localizations" : { @@ -16434,6 +16672,18 @@ "Servers & Network" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Service Account Key" : { + + }, + "Service Account Key Invalid" : { + + }, + "Service Account Key Is on the Mac" : { + + }, + "Service Account Key Needed" : { + }, "Service Name" : { "localizations" : { @@ -16494,6 +16744,9 @@ "Services" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Session Token (optional)" : { + }, "Set a passphrase to include passwords." : { "localizations" : { @@ -16522,6 +16775,12 @@ } } } + }, + "Set the application (client) ID for this connection on the Mac." : { + + }, + "Set the AWS Region in this connection's Authentication section." : { + }, "Set to SSH Agent on the Mac. iPhone and iPad sign in with a private key." : { @@ -16931,6 +17190,12 @@ } } } + }, + "Sign in to IAM Identity Center to use this connection." : { + + }, + "Sign in to IAM Identity Center with your browser?" : { + }, "Sign in to iCloud in the Settings app to sync your connections." : { "localizations" : { @@ -16988,6 +17253,23 @@ } } }, + "Sign In with IAM Identity Center" : { + + }, + "Sign in with your Google account to use this connection." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Sign-In Denied" : { + + }, + "Sign-In Timed Out" : { + + }, + "Signing the service account token failed." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Skip" : { "extractionState" : "stale", "localizations" : { @@ -17505,6 +17787,9 @@ "SSL mode “%@” is not recognized, so the connection imports with SSL required." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "SSO Region" : { + }, "Stack" : { "extractionState" : "manual", @@ -17517,6 +17802,9 @@ "Star of Life" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Start URL" : { + }, "starts with" : { "localizations" : { @@ -18865,6 +19153,12 @@ } } } + }, + "Tap Retry to sign in again." : { + + }, + "Tap Retry to sign in to IAM Identity Center." : { + }, "Tap Retry to try again." : { "localizations" : { @@ -18893,6 +19187,9 @@ } } } + }, + "Tap Retry, then approve the sign-in in the browser before the code expires." : { + }, "Team" : { "extractionState" : "manual", @@ -19367,6 +19664,18 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The credentials file can't be read." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The credentials file has no %@ field." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The credentials point at %@, which is not a Google endpoint." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The connections could not be saved." : { }, @@ -19596,6 +19905,9 @@ } } } + }, + "The IAM Identity Center sign-in on this device has expired." : { + }, "The jump host keys on this device could not be read. Close the form and open it again to change them." : { @@ -20109,6 +20421,9 @@ } } } + }, + "The profile is read from files on the Mac. A key added here signs in on this device only. The Mac's connection does not change." : { + }, "The previous session is still closing" : { "extractionState" : "manual", @@ -20254,6 +20569,13 @@ } } } + }, + "The server answered with something other than HTTP." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The server did not encrypt the connection, so TablePro closed it. An IAM sign-in needs TLS." : { + }, "The saved queries could not be read. Nothing was exported." : { @@ -20409,6 +20731,17 @@ } } }, + "The service account key is a file on the Mac, which this device cannot read." : { + + }, + "The service account key is not valid JSON." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The service account private key can't be read." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The socket path contains a control or invisible character." : { }, @@ -20615,6 +20948,9 @@ }, "There is no statement to run." : { + }, + "These settings stay on this device. The Mac reads them from its AWS profile." : { + }, "This account uses a password verifier the database driver does not support." : { "extractionState" : "manual", @@ -20900,6 +21236,24 @@ } } } + }, + "This connection signs in with a Google Cloud service account, and this device does not have its key." : { + + }, + "This connection signs in with an AWS access key, and this device does not have it." : { + + }, + "This connection signs in with IAM Identity Center, which is not set up on this device." : { + + }, + "This connection signs in with IAM, not a password. Check that the database user is set up for IAM sign-in." : { + + }, + "This connection signs in with the AWS profile \"%@\", which is read from files on the Mac." : { + + }, + "This connection signs in with the gcloud credentials on the Mac." : { + }, "This connection uses a socket on a Mac and cannot be opened on iPhone or iPad." : { "extractionState" : "manual", @@ -22025,6 +22379,9 @@ } } } + }, + "Try again in a moment." : { + }, "Try again or check your connection." : { "localizations" : { diff --git a/apple/TableProMobile/TableProMobile/Models/ConnectionIAMFormState.swift b/apple/TableProMobile/TableProMobile/Models/ConnectionIAMFormState.swift new file mode 100644 index 0000000000..18f1dcab33 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Models/ConnectionIAMFormState.swift @@ -0,0 +1,169 @@ +import Foundation +import os +import TableProDatabase +import TableProModels +import TableProPluginKit + +nonisolated struct ConnectionIAMSecrets: Equatable, Sendable { + var secretAccessKey = "" + var sessionToken = "" + var serviceAccountKey = "" + var device = DeviceIAMCredentials() + + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionIAMSecrets") + + static func stored(for connectionId: UUID, in store: any SecureStore) -> ConnectionIAMSecrets { + var secrets = ConnectionIAMSecrets() + secrets.secretAccessKey = storedValue(.awsSecretAccessKey, for: connectionId, in: store) + secrets.sessionToken = storedValue(.awsSessionToken, for: connectionId, in: store) + secrets.serviceAccountKey = storedValue(.gcpServiceAccountKey, for: connectionId, in: store) + do { + secrets.device = try DeviceIAMCredentials.load(for: connectionId, from: store) + } catch { + logger.error("Reading the device sign-in settings failed: \(error.localizedDescription, privacy: .public)") + } + return secrets + } + + // Keeps a value only once the Keychain took it, so a failed write stays a change. + mutating func persist(_ writes: ConnectionIAMSecretWrites, for connectionId: UUID, in store: any SecureStore) -> Bool { + var succeeded = true + if let value = writes.secretAccessKey { + if Self.write(value, as: .awsSecretAccessKey, for: connectionId, in: store) { + secretAccessKey = value + } else { + succeeded = false + } + } + if let value = writes.sessionToken { + if Self.write(value, as: .awsSessionToken, for: connectionId, in: store) { + sessionToken = value + } else { + succeeded = false + } + } + if let value = writes.serviceAccountKey { + if Self.write(value, as: .gcpServiceAccountKey, for: connectionId, in: store) { + serviceAccountKey = value + } else { + succeeded = false + } + } + if let value = writes.device { + do { + try value.save(for: connectionId, to: store) + device = value + } catch { + Self.logger.error("Failed to store the device sign-in settings: \(error.localizedDescription, privacy: .public)") + succeeded = false + } + } + return succeeded + } + + private static func storedValue( + _ kind: ConnectionSecretKind, + for connectionId: UUID, + in store: any SecureStore + ) -> String { + do { + return try store.retrieve(forKey: kind.account(for: connectionId)) ?? "" + } catch { + logger.error("Reading a sign-in secret failed: \(error.localizedDescription, privacy: .public)") + return "" + } + } + + private static func write( + _ value: String, + as kind: ConnectionSecretKind, + for connectionId: UUID, + in store: any SecureStore + ) -> Bool { + let account = kind.account(for: connectionId) + do { + if value.isEmpty { + try store.delete(forKey: account) + } else { + try store.store(value, forKey: account) + } + return true + } catch { + logger.error("Failed to store a sign-in secret: \(error.localizedDescription, privacy: .public)") + return false + } + } +} + +// An empty string deletes the item. +nonisolated struct ConnectionIAMSecretWrites: Equatable, Sendable { + var secretAccessKey: String? + var sessionToken: String? + var serviceAccountKey: String? + var device: DeviceIAMCredentials? +} + +nonisolated struct IdentityCenterSetupState: Equatable, Sendable { + nonisolated enum Activity: Equatable, Sendable { + case idle + case signingIn + case loadingRoles + } + + nonisolated struct Request: Equatable, Sendable { + nonisolated enum Kind: Equatable, Sendable { + case signIn + case roles(accountId: String) + } + + let kind: Kind + let id = UUID() + } + + var activity = Activity.idle + var accounts: [AWSSSOAccount] = [] + var roles: [AWSSSOAccountRole] = [] + var error: String? + var request: Request? +} + +nonisolated struct ConnectionIAMFormState: Equatable, Sendable { + var method: IAMSignIn? + var region = "" + var accessKeyId = "" + var profileName = "" + var rdsEndpoint = "" + var replicationGroupId = "" + var secrets = ConnectionIAMSecrets() + var identityCenter = IdentityCenterSetupState() + var serviceAccountKeyFileError: String? + + init() {} + + init(connection: DatabaseConnection) { + typealias Field = IAMSignIn.Field + let fields = connection.additionalFields + method = connection.iamSignIn + region = fields[Field.region] ?? "" + accessKeyId = fields[Field.accessKeyId] ?? "" + profileName = fields[Field.profileName] ?? "" + rdsEndpoint = fields[Field.rdsEndpoint] ?? "" + replicationGroupId = fields[Field.replicationGroupId] ?? "" + } + + var options: ConnectionFormEdits.IAMOptions { + ConnectionFormEdits.IAMOptions( + method: method, + region: region, + accessKeyId: accessKeyId, + profileName: profileName, + rdsEndpoint: rdsEndpoint, + replicationGroupId: replicationGroupId + ) + } + + mutating func keepMethodOffered(for type: DatabaseType) { + guard let method, !IAMSignIn.methods(for: type).contains(method) else { return } + self.method = nil + } +} diff --git a/apple/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift b/apple/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift index fa0ce8e25f..ea22147d19 100644 --- a/apple/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift +++ b/apple/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift @@ -6,6 +6,10 @@ nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { case keyPassphrase case sshPrivateKey case jumpHostKeys + case awsSecretAccessKey + case awsSessionToken + case gcpServiceAccountKey + case deviceIAM var prefix: String { switch self { @@ -14,20 +18,34 @@ nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { case .keyPassphrase: "com.TablePro.keypassphrase." case .sshPrivateKey: "com.TablePro.sshkeydata." case .jumpHostKeys: "com.TablePro.jumphostkeys." + case .awsSecretAccessKey: "com.TablePro.plugin.awsSecretAccessKey." + case .awsSessionToken: "com.TablePro.plugin.awsSessionToken." + case .gcpServiceAccountKey: "com.TablePro.plugin.gcpServiceAccountKey." + case .deviceIAM: "com.TablePro.deviceiam." } } var isSweptWhenOrphaned: Bool { switch self { - case .password, .sshPassword, .keyPassphrase: true + case .password, .sshPassword, .keyPassphrase, + .awsSecretAccessKey, .awsSessionToken, .gcpServiceAccountKey, .deviceIAM: true case .sshPrivateKey, .jumpHostKeys: false } } + // A key added for this device only must never reach the Mac's copy of the connection. + var isDeviceOnly: Bool { + self == .deviceIAM + } + func account(for connectionId: UUID) -> String { prefix + connectionId.uuidString } + static func isDeviceOnly(account: String) -> Bool { + allCases.contains { $0.isDeviceOnly && account.hasPrefix($0.prefix) } + } + static func sweptConnectionId(inAccount account: String) -> UUID? { let swept = allCases.filter(\.isSweptWhenOrphaned) guard let kind = swept.first(where: { account.hasPrefix($0.prefix) }) else { return nil } diff --git a/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index b9f6c503ad..93a040823a 100644 --- a/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -10,15 +10,18 @@ nonisolated final class IOSDriverFactory: DriverFactory { private let bookmarkStore: FileBookmarkStore private let materializer: CertificateMaterializer private let localFiles: LocalDatabaseFileLocator + private let mint: IAMPasswordMint init( bookmarkStore: FileBookmarkStore = FileBookmarkStore(), materializer: CertificateMaterializer = CertificateMaterializer(), - localFiles: LocalDatabaseFileLocator = .live + localFiles: LocalDatabaseFileLocator = .live, + mint: IAMPasswordMint = IAMPasswordMint() ) { self.bookmarkStore = bookmarkStore self.materializer = materializer self.localFiles = localFiles + self.mint = mint } private func sqliteSource(for connection: DatabaseConnection) throws -> LocalDatabaseFileSource { @@ -68,17 +71,39 @@ nonisolated final class IOSDriverFactory: DriverFactory { } } - private func ssl(for connection: DatabaseConnection) throws -> DriverSSLConfiguration { + func ssl(for connection: DatabaseConnection, serverName: String?) throws -> DriverSSLConfiguration { let declared = DriverSSLConfiguration( sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration ) - let resolved = declared.applying(try materializer.materialize(for: connection.id)) + var resolved = declared.applying(try materializer.materialize(for: connection.id)) try CertificatePreflight.validate(resolved) + if connection.iamSignIn != nil, !resolved.isEnabled { + // A token is never sent in plaintext. SSL was off, so no certificate belongs to the raised mode. + resolved = DriverSSLConfiguration(mode: .require) + } + resolved.serverName = serverName return resolved } + func resolvePassword( + for connection: DatabaseConnection, + stored: String?, + secureStore: any SecureStore + ) async throws -> String? { + guard let method = connection.iamSignIn else { return stored } + return try await mint.password(for: connection, method: method, secureStore: secureStore) + } + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { + try createDriver(for: connection, password: password, preTunnelHost: nil) + } + + func createDriver( + for connection: DatabaseConnection, + password: String?, + preTunnelHost: String? + ) throws -> any DatabaseDriver { switch connection.type { case .sqlite where connection.isSample: return SQLiteDriver(source: .file(try SampleDatabaseInstaller.live.installIfNeeded())) @@ -96,9 +121,10 @@ nonisolated final class IOSDriverFactory: DriverFactory { user: connection.username, password: password ?? "", database: connection.database, - ssl: try ssl(for: connection), + ssl: try ssl(for: connection, serverName: preTunnelHost), databaseType: connection.type, - connectionEncoding: MySQLConnectionEncoding(additionalFields: connection.additionalFields) + connectionEncoding: MySQLConnectionEncoding(additionalFields: connection.additionalFields), + signsInWithToken: connection.iamSignIn != nil ) case .postgresql, .redshift: return PostgreSQLDriver( @@ -107,7 +133,7 @@ nonisolated final class IOSDriverFactory: DriverFactory { user: connection.username, password: password ?? "", database: connection.database, - ssl: try ssl(for: connection), + ssl: try ssl(for: connection, serverName: preTunnelHost), databaseType: connection.type ) case .redis: @@ -121,7 +147,7 @@ nonisolated final class IOSDriverFactory: DriverFactory { username: connection.username, password: password, database: dbIndex, - ssl: try ssl(for: connection) + ssl: try ssl(for: connection, serverName: preTunnelHost) ) case .mssql: return MSSQLDriver(connection: connection, password: password) diff --git a/apple/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/apple/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index 23f3620aaa..a5d685650d 100644 --- a/apple/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift +++ b/apple/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -50,10 +50,14 @@ nonisolated final class KeychainSecureStore: SecureStore { : kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly } + static func synchronizes(_ key: String, syncsPasswords: Bool) -> Bool { + syncsPasswords && !ConnectionSecretKind.isDeviceOnly(account: key) + } + func store(_ value: String, forKey key: String) throws { guard let data = value.data(using: .utf8) else { return } - let synchronizable = AppPreferences.syncsPasswords + let synchronizable = Self.synchronizes(key, syncsPasswords: AppPreferences.syncsPasswords) let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -118,15 +122,7 @@ nonisolated final class KeychainSecureStore: SecureStore { } } - /// Remove passwords left under ids no saved connection uses, such as a test connection an older - /// build stored and was killed before deleting. - /// - /// Three limits, because this deletes by prefix and cannot tell a throwaway id from an id it has - /// simply not heard of yet. An empty valid set means the connections have not loaded, which is - /// every launch before the first sync merge, and sweeping then would delete all of them. Only - /// device-local items are considered: a synchronizable item belongs to iCloud Keychain, so - /// deleting one here removes it from the Mac that wrote it too. And a pasted private key is - /// never swept, because a connection sync has not delivered yet may hold its only copy. + // Device-local items only: deleting a synced one here would remove it from the Mac that wrote it. static func cleanOrphanedCredentials(validConnectionIds: Set) { guard !validConnectionIds.isEmpty else { return } @@ -149,9 +145,7 @@ nonisolated final class KeychainSecureStore: SecureStore { } } - /// The sweep's own delete. `delete(forKey:)` matches `kSecAttrSynchronizableAny` because an - /// ordinary delete has to reach the item whichever it is; here that would let a device-local - /// match take an iCloud-shared item of the same name with it. + // Unlike delete(forKey:), never matches a synced item of the same name. private static func deleteDeviceLocal(forKey key: String, accessGroup: String?) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/apple/TableProMobile/TableProMobile/SignIn/AWSSSOSignIn.swift b/apple/TableProMobile/TableProMobile/SignIn/AWSSSOSignIn.swift new file mode 100644 index 0000000000..fffa38b7c1 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SignIn/AWSSSOSignIn.swift @@ -0,0 +1,110 @@ +import Foundation +import os +import TableProDatabase +import TableProPluginKit + +// IAM Identity Center sign-in with the device authorization flow, as `EntraSignIn` does it: no +// redirect URI, and the system browser approves the code. +enum AWSSSOSignIn { + nonisolated struct Setup: Equatable, Sendable { + let startUrl: String + let region: String + } + + typealias Authorize = @Sendable ( + _ setup: Setup, + _ presentCode: @escaping @Sendable (URL, String) -> Void + ) async throws -> AWSSSOTokenCacheEntry + + static let liveAuthorize: Authorize = { setup, presentCode in + try await AWSSSOLogin.authorize( + region: setup.region, + startUrl: setup.startUrl, + presentCode: presentCode, + session: AWSHTTP.shared + ) + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "AWSSSOSignIn") + + static func needsSignIn(_ error: Error) -> Bool { + switch error { + case let iamError as IAMSignInError: + return iamError.needsSignIn + case let ssoError as AWSSSOError: + return endsTheSignIn(ssoError) + default: + return false + } + } + + // The saved token is gone or the portal no longer takes it, which only a new sign-in replaces. + static func endsTheSignIn(_ error: AWSSSOError) -> Bool { + switch error { + case .tokenCacheNotFound, .tokenCacheMalformed, .tokenExpired, .sessionUnauthorized: + return true + case .configReadFailed, .profileNotFound, .profileMissingFields, .sessionNotFound, + .sessionMissingFields, .profileMissingUrlOrRegion, .urlBuildFailed, .networkFailure, + .invalidResponse, .roleNotAccessible, .portalError, .responseDecodeFailed, + .credentialsAlreadyExpired: + return false + } + } + + static func setup(for connectionId: UUID, secureStore: any SecureStore) -> Setup? { + let device: DeviceIAMCredentials + do { + device = try DeviceIAMCredentials.load(for: connectionId, from: secureStore) + } catch { + logger.error("Reading the IAM Identity Center settings failed: \(error.localizedDescription, privacy: .public)") + return nil + } + let startUrl = device.ssoStartUrl.trimmingCharacters(in: .whitespacesAndNewlines) + let region = device.ssoRegion.trimmingCharacters(in: .whitespacesAndNewlines) + guard !startUrl.isEmpty, !region.isEmpty else { return nil } + return Setup(startUrl: startUrl, region: region) + } + + static func method( + _ setup: Setup, + tokens: AWSSSOTokenStore = AWSSSOTokenStore(), + authorize: @escaping Authorize = liveAuthorize + ) -> SignInMethod { + SignInMethod( + question: ConnectionPrompt( + title: String(localized: "AWS SSO Sign-In Required"), + message: String(localized: "Sign in to IAM Identity Center with your browser?"), + confirmTitle: String(localized: "Sign In") + ), + signIn: { prompts, generation in + try await signIn(setup, tokens: tokens, authorize: authorize) { url, userCode in + Task { @MainActor in + present(code: userCode, url: url, prompts: prompts, generation: generation) + } + } + } + ) + } + + // The token is saved by start URL, so every connection on this Identity Center, and Test + // Connection, uses the one sign-in. + static func signIn( + _ setup: Setup, + tokens: AWSSSOTokenStore = AWSSSOTokenStore(), + authorize: @escaping Authorize = liveAuthorize, + presentCode: @escaping @Sendable (URL, String) -> Void + ) async throws { + let entry = try await authorize(setup, presentCode) + try tokens.save(entry, startUrl: setup.startUrl) + } + + // The verification link carries the code, so the browser only asks to confirm it. + private static func present(code: String, url: URL, prompts: ConnectionPromptQueue, generation: Int) { + SignInOffer.showCode( + String(format: String(localized: "Confirm the code %@ in the browser to finish signing in."), code), + opening: url, + prompts: prompts, + generation: generation + ) + } +} diff --git a/apple/TableProMobile/TableProMobile/SignIn/AWSSSOTokenStore.swift b/apple/TableProMobile/TableProMobile/SignIn/AWSSSOTokenStore.swift new file mode 100644 index 0000000000..a85313461f --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SignIn/AWSSSOTokenStore.swift @@ -0,0 +1,132 @@ +import Foundation +import os +import Security +import TableProDatabase +import TableProPluginKit + +// Keyed by start URL, as the AWS CLI cache is, so every connection on one Identity Center shares a sign-in. +nonisolated struct AWSSSOTokenStore: Sendable { + static let service = "com.TablePro.AWSSSO.token" + + private static let logger = Logger(subsystem: "com.TablePro", category: "AWSSSOTokenStore") + + private let items: any SecureStore + + init() { + self.init(items: DeviceOnlyKeychain(service: Self.service)) + } + + init(items: any SecureStore) { + self.items = items + } + + static func account(forStartUrl startUrl: String) -> String { + var normalized = startUrl.trimmingCharacters(in: .whitespacesAndNewlines) + while normalized.hasSuffix("/") { + normalized.removeLast() + } + return normalized + } + + func token(startUrl: String) -> AWSSSOTokenCacheEntry? { + let stored: String? + do { + stored = try items.retrieve(forKey: Self.account(forStartUrl: startUrl)) + } catch { + Self.logger.error("Reading the IAM Identity Center token failed: \(error.localizedDescription, privacy: .public)") + return nil + } + guard let stored, let token = try? JSONDecoder().decode(StoredToken.self, from: Data(stored.utf8)) else { + return nil + } + return token.entry + } + + func save(_ entry: AWSSSOTokenCacheEntry, startUrl: String) throws { + let data = try JSONEncoder().encode(StoredToken(entry)) + try items.store(String(decoding: data, as: UTF8.self), forKey: Self.account(forStartUrl: startUrl)) + } + + func clear(startUrl: String) { + do { + try items.delete(forKey: Self.account(forStartUrl: startUrl)) + } catch { + Self.logger.error("Clearing the IAM Identity Center token failed: \(error.localizedDescription, privacy: .public)") + } + } +} + +nonisolated private struct StoredToken: Codable { + let accessToken: String + let expiresAt: Date + let region: String + let startUrl: String + let refreshToken: String? + let clientId: String? + let clientSecret: String? + let registrationExpiresAt: Date? + + init(_ entry: AWSSSOTokenCacheEntry) { + accessToken = entry.accessToken + expiresAt = entry.expiresAt + region = entry.region + startUrl = entry.startUrl + refreshToken = entry.refreshToken + clientId = entry.clientId + clientSecret = entry.clientSecret + registrationExpiresAt = entry.registrationExpiresAt + } + + var entry: AWSSSOTokenCacheEntry { + AWSSSOTokenCacheEntry( + accessToken: accessToken, + expiresAt: expiresAt, + region: region, + startUrl: startUrl, + refreshToken: refreshToken, + clientId: clientId, + clientSecret: clientSecret, + registrationExpiresAt: registrationExpiresAt + ) + } +} + +nonisolated private struct DeviceOnlyKeychain: SecureStore { + let service: String + + private func baseQuery(_ account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + } + + func store(_ value: String, forKey key: String) throws { + SecItemDelete(baseQuery(key) as CFDictionary) + var query = baseQuery(key) + query[kSecValueData as String] = Data(value.utf8) + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + query[kSecAttrSynchronizable as String] = false + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.storeFailed(status) } + } + + func retrieve(forKey key: String) throws -> String? { + var query = baseQuery(key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw KeychainError.retrieveFailed(status) } + guard let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + func delete(forKey key: String) throws { + let status = SecItemDelete(baseQuery(key) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.deleteFailed(status) } + } +} diff --git a/apple/TableProMobile/TableProMobile/SignIn/DeviceIAMCredentials.swift b/apple/TableProMobile/TableProMobile/SignIn/DeviceIAMCredentials.swift new file mode 100644 index 0000000000..8c75adfb34 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SignIn/DeviceIAMCredentials.swift @@ -0,0 +1,101 @@ +import Foundation +import os +import TableProDatabase +import TableProPluginKit + +// Never in `additionalFields`, which syncs to the Mac and redials an open connection. +nonisolated struct DeviceIAMCredentials: Codable, Equatable, Sendable { + var accessKeyId = "" + var secretAccessKey = "" + var sessionToken = "" + var serviceAccountKey = "" + var ssoStartUrl = "" + var ssoRegion = "" + var ssoAccountId = "" + var ssoRoleName = "" + + private static let logger = Logger(subsystem: "com.TablePro", category: "DeviceIAMCredentials") + + nonisolated private enum CodingKeys: String, CodingKey { + case accessKeyId, secretAccessKey, sessionToken, serviceAccountKey + case ssoStartUrl, ssoRegion, ssoAccountId, ssoRoleName + } + + var isEmpty: Bool { + self == DeviceIAMCredentials() + } + + var awsCredentials: AWSCredentials? { + let id = Self.trimmed(accessKeyId) + let secret = Self.trimmed(secretAccessKey) + guard !id.isEmpty, !secret.isEmpty else { return nil } + let token = Self.trimmed(sessionToken) + return AWSCredentials(accessKeyId: id, secretAccessKey: secret, sessionToken: token.isEmpty ? nil : token) + } + + var ssoSettings: AWSSSOProfileSettings? { + let startUrl = Self.trimmed(ssoStartUrl) + let region = Self.trimmed(ssoRegion) + let accountId = Self.trimmed(ssoAccountId) + let roleName = Self.trimmed(ssoRoleName) + guard !startUrl.isEmpty, !region.isEmpty, !accountId.isEmpty, !roleName.isEmpty else { return nil } + return AWSSSOProfileSettings( + accountId: accountId, + roleName: roleName, + startUrl: startUrl, + region: region, + ssoSession: nil + ) + } + + static func account(for connectionId: UUID) -> String { + ConnectionSecretKind.deviceIAM.account(for: connectionId) + } + + static func load(for connectionId: UUID, from store: any SecureStore) throws -> DeviceIAMCredentials { + guard let stored = try store.retrieve(forKey: account(for: connectionId)), !stored.isEmpty else { + return DeviceIAMCredentials() + } + do { + return try JSONDecoder().decode(DeviceIAMCredentials.self, from: Data(stored.utf8)) + } catch { + logger.error("The device sign-in settings of \(connectionId.uuidString, privacy: .public) do not decode") + return DeviceIAMCredentials() + } + } + + func save(for connectionId: UUID, to store: any SecureStore) throws { + let account = Self.account(for: connectionId) + guard !isEmpty else { + try store.delete(forKey: account) + return + } + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + try store.store(String(decoding: try encoder.encode(self), as: UTF8.self), forKey: account) + } + + private static func trimmed(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// In an extension, so the memberwise init stays. +nonisolated extension DeviceIAMCredentials { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + func value(_ key: CodingKeys) throws -> String { + try container.decodeIfPresent(String.self, forKey: key) ?? "" + } + self.init( + accessKeyId: try value(.accessKeyId), + secretAccessKey: try value(.secretAccessKey), + sessionToken: try value(.sessionToken), + serviceAccountKey: try value(.serviceAccountKey), + ssoStartUrl: try value(.ssoStartUrl), + ssoRegion: try value(.ssoRegion), + ssoAccountId: try value(.ssoAccountId), + ssoRoleName: try value(.ssoRoleName) + ) + } +} diff --git a/apple/TableProMobile/TableProMobile/SignIn/EntraSignIn.swift b/apple/TableProMobile/TableProMobile/SignIn/EntraSignIn.swift index a25b2835e7..67782176ca 100644 --- a/apple/TableProMobile/TableProMobile/SignIn/EntraSignIn.swift +++ b/apple/TableProMobile/TableProMobile/SignIn/EntraSignIn.swift @@ -1,69 +1,49 @@ -// -// EntraSignIn.swift -// TableProMobile -// -// Microsoft Entra ID sign-in for SQL Server connections. The device code flow needs no redirect -// URI and no in-app browser, so it works here with the system browser and a one-time code. -// - import Foundation -import TableProDatabase import TableProPluginKit import UIKit +// The device code flow needs no redirect URI and no in-app browser, so it works here with the +// system browser and a one-time code. enum EntraSignIn { static func needsSignIn(_ error: Error) -> Bool { guard let entraError = error as? EntraOAuthError else { return false } return entraError.needsSignIn } - /// Offers the sign-in and runs it. Returns true when it completed, so the caller can retry. - /// The question and the code both belong to the attempt, so they go through its own queue. - static func offer(fields: [String: String], prompts: ConnectionPromptQueue) async -> Bool { - let generation = prompts.generation - let confirmed = await prompts.ask( - ConnectionPrompt( + static func method(fields: [String: String]) -> SignInMethod { + SignInMethod( + question: ConnectionPrompt( title: String(localized: "Microsoft Entra ID Sign-In Required"), message: String(localized: "Sign in to Microsoft Entra ID with your browser?"), confirmTitle: String(localized: "Sign In") - ) - ) - guard confirmed else { return false } - - do { - try await EntraCredentialResolver.shared.signIn( - fields: fields, - presentCode: { url, userCode in - Task { @MainActor in - present(code: userCode, url: url, prompts: prompts, generation: generation) + ), + signIn: { prompts, generation in + try await EntraCredentialResolver.shared.signIn( + fields: fields, + presentCode: { url, userCode in + Task { @MainActor in + present(code: userCode, url: url, prompts: prompts, generation: generation) + } } - } - ) - return true - } catch { - return false - } + ) + } + ) } - /// Puts the code on the pasteboard and opens the verification page. Microsoft returns a bare - /// URL and expects the code to be entered there, so it has to be shown as well as copied. - @MainActor + // Microsoft returns a bare URL and expects the code to be entered there, so it has to be shown + // as well as copied. private static func present(code: String, url: URL, prompts: ConnectionPromptQueue, generation: Int) { UIPasteboard.general.string = code - prompts.notify( - ConnectionPrompt( - title: String(localized: "Finish Signing In"), - message: String( - format: String( - localized: "Enter the code %@ in the browser to finish signing in. It is on your clipboard." - ), - code + SignInOffer.showCode( + String( + format: String( + localized: "Enter the code %@ in the browser to finish signing in. It is on your clipboard." ), - confirmTitle: String(localized: "OK"), - style: .notice + code ), + opening: url, + prompts: prompts, generation: generation ) - UIApplication.shared.open(url) } } diff --git a/apple/TableProMobile/TableProMobile/SignIn/IAMPasswordMint.swift b/apple/TableProMobile/TableProMobile/SignIn/IAMPasswordMint.swift new file mode 100644 index 0000000000..4c3c13fa52 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SignIn/IAMPasswordMint.swift @@ -0,0 +1,190 @@ +import Foundation +import TableProDatabase +import TableProGoogleCloud +import TableProModels +import TableProPluginKit + +// The token is never stored: replayed after it expires, it fails the next sign-in. +nonisolated struct IAMPasswordMint: Sendable { + typealias RoleCredentialsFetch = @Sendable ( + _ accessToken: String, + _ settings: AWSSSOProfileSettings, + _ profileName: String + ) async throws -> AWSCredentials + typealias GoogleTokenFetch = @Sendable (GoogleServiceAccountKey) async throws -> String + + static let liveRoleCredentials: RoleCredentialsFetch = { accessToken, settings, profileName in + let role = try await AWSSSO.fetchRoleCredentials( + accessToken: accessToken, + settings: settings, + profileName: profileName, + session: AWSHTTP.shared + ) + return AWSCredentials( + accessKeyId: role.accessKeyId, + secretAccessKey: role.secretAccessKey, + sessionToken: role.sessionToken, + expiration: role.expiration + ) + } + + static let liveGoogleToken: GoogleTokenFetch = { key in + try await GoogleTokenProviders.serviceAccount( + key, + scopes: GoogleCloudSQLLogin.scopes, + http: URLSessionGoogleHTTPClient() + ).accessToken() + } + + // The Mac's view after its tunnel opens: the driver dials loopback and the saved host is the pre-tunnel one. + static let tunnelLoopbackHost = "127.0.0.1" + + private static let keyPadding = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "\u{FEFF}")) + + private let ssoTokens: AWSSSOTokenStore + private let fetchRoleCredentials: RoleCredentialsFetch + private let fetchGoogleToken: GoogleTokenFetch + private let now: @Sendable () -> Date + + init( + ssoTokens: AWSSSOTokenStore = AWSSSOTokenStore(), + fetchRoleCredentials: @escaping RoleCredentialsFetch = IAMPasswordMint.liveRoleCredentials, + fetchGoogleToken: @escaping GoogleTokenFetch = IAMPasswordMint.liveGoogleToken, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.ssoTokens = ssoTokens + self.fetchRoleCredentials = fetchRoleCredentials + self.fetchGoogleToken = fetchGoogleToken + self.now = now + } + + func password( + for connection: DatabaseConnection, + method: IAMSignIn, + secureStore: any SecureStore + ) async throws -> String { + switch method { + case .awsAccessKey: + return try sign(connection, with: Self.accessKeyCredentials(for: connection, in: secureStore)) + case .awsProfile: + return try sign(connection, with: Self.profileCredentials(for: connection, in: secureStore)) + case .awsSSO: + let credentials = try await ssoCredentials(for: connection, in: secureStore) + return try sign(connection, with: credentials) + case .googleServiceAccount: + let value = try secureStore.retrieve( + forKey: ConnectionSecretKind.gcpServiceAccountKey.account(for: connection.id) + ) + return try await fetchGoogleToken(Self.serviceAccountKey(value, whenMissing: .serviceAccountKeyMissing)) + case .googleApplicationDefault: + let device = try DeviceIAMCredentials.load(for: connection.id, from: secureStore) + return try await fetchGoogleToken( + Self.serviceAccountKey(device.serviceAccountKey, whenMissing: .applicationDefaultNeedsDeviceKey) + ) + } + } + + static func target(for connection: DatabaseConnection) -> AWSIAMTokenTarget { + if IAMSignIn.engine(for: connection.type)?.awsService == .elastiCache { + let saved = DriverSSLConfiguration(sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration) + return .elastiCache(host: connection.host, tlsDisabled: !saved.isEnabled) + } + let defaultPort = Int(connection.type.defaultPort) ?? connection.port + guard connection.sshEnabled, connection.sshConfiguration != nil else { + return .rds( + configuredHost: connection.host, + configuredPort: connection.port, + preTunnelHost: nil, + preTunnelPort: nil, + defaultPort: defaultPort + ) + } + return .rds( + configuredHost: tunnelLoopbackHost, + configuredPort: connection.port, + preTunnelHost: connection.host, + preTunnelPort: connection.port, + defaultPort: defaultPort + ) + } + + static func serviceAccountKey( + _ value: String?, + whenMissing missing: IAMSignInError + ) throws -> GoogleServiceAccountKey { + guard let value, !value.trimmingCharacters(in: keyPadding).isEmpty else { throw missing } + do { + return try GoogleServiceAccountKey.parse(fieldValue: value, readFile: { _ in nil }) + } catch GoogleAuthError.credentialFileUnreadable { + throw IAMSignInError.serviceAccountKeyIsMacFile + } + } + + private func sign(_ connection: DatabaseConnection, with credentials: AWSCredentials) throws -> String { + try Self.target(for: connection).token( + username: connection.username, + fields: connection.additionalFields, + credentials: credentials, + now: now() + ) + } + + private static func accessKeyCredentials( + for connection: DatabaseConnection, + in secureStore: any SecureStore + ) throws -> AWSCredentials { + let accessKeyId = connection.additionalFields[IAMSignIn.Field.accessKeyId] ?? "" + let secretAccessKey = try Self.secret(.awsSecretAccessKey, of: connection, in: secureStore) ?? "" + guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else { throw IAMSignInError.accessKeyMissing } + return AWSCredentials( + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + sessionToken: try Self.secret(.awsSessionToken, of: connection, in: secureStore) + ) + } + + private static func profileCredentials( + for connection: DatabaseConnection, + in secureStore: any SecureStore + ) throws -> AWSCredentials { + let device = try DeviceIAMCredentials.load(for: connection.id, from: secureStore) + guard let credentials = device.awsCredentials else { + throw IAMSignInError.profileNeedsDeviceKey(profile: profileName(of: connection)) + } + return credentials + } + + private func ssoCredentials( + for connection: DatabaseConnection, + in secureStore: any SecureStore + ) async throws -> AWSCredentials { + let device = try DeviceIAMCredentials.load(for: connection.id, from: secureStore) + guard let settings = device.ssoSettings else { throw IAMSignInError.ssoNotSetUp } + guard let token = ssoTokens.token(startUrl: settings.startUrl), token.expiresAt > now() else { + throw IAMSignInError.ssoSignInRequired + } + do { + return try await fetchRoleCredentials(token.accessToken, settings, Self.profileName(of: connection)) + } catch AWSSSOError.sessionUnauthorized { + ssoTokens.clear(startUrl: settings.startUrl) + throw IAMSignInError.ssoSignInRequired + } + } + + private static func profileName(of connection: DatabaseConnection) -> String { + nonEmpty(connection.additionalFields[IAMSignIn.Field.profileName]) ?? "default" + } + + private static func secret( + _ kind: ConnectionSecretKind, + of connection: DatabaseConnection, + in secureStore: any SecureStore + ) throws -> String? { + nonEmpty(try secureStore.retrieve(forKey: kind.account(for: connection.id))) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} diff --git a/apple/TableProMobile/TableProMobile/SignIn/IAMSignIn.swift b/apple/TableProMobile/TableProMobile/SignIn/IAMSignIn.swift new file mode 100644 index 0000000000..80861b09d4 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SignIn/IAMSignIn.swift @@ -0,0 +1,108 @@ +import Foundation +import TableProModels +import TableProPluginKit + +// A local copy of the Mac's IAMSignInMethod: a non-frozen PluginKit enum would force `@unknown default` into Mac switches. +nonisolated enum IAMSignIn: CaseIterable, Hashable, Identifiable, Sendable { + case awsAccessKey + case awsProfile + case awsSSO + case googleApplicationDefault + case googleServiceAccount + + nonisolated enum AWSService: Equatable, Sendable { + case rds + case elastiCache + } + + nonisolated struct Engine: Equatable, Sendable { + let methods: [IAMSignIn] + let awsService: AWSService + } + + nonisolated enum Field { + static let method = "awsAuth" + static let region = "awsRegion" + static let accessKeyId = "awsAccessKeyId" + static let profileName = "awsProfileName" + static let rdsEndpoint = "awsRDSEndpoint" + static let replicationGroupId = "awsReplicationGroupId" + } + + static let passwordPickerValue = "off" + + private static let aws: [IAMSignIn] = [.awsAccessKey, .awsProfile, .awsSSO] + private static let googleCloud: [IAMSignIn] = [.googleApplicationDefault, .googleServiceAccount] + + // The field sets the Mac declares for each engine; any other type ignores its `awsAuth`. + static func engine(for type: DatabaseType) -> Engine? { + switch type { + case .mysql, .postgresql: + Engine(methods: aws + googleCloud, awsService: .rds) + case .mariadb: + Engine(methods: aws, awsService: .rds) + case .redis: + Engine(methods: aws, awsService: .elastiCache) + default: + nil + } + } + + static func methods(for type: DatabaseType) -> [IAMSignIn] { + engine(for: type)?.methods ?? [] + } + + init?(pickerValue: String?) { + guard let pickerValue, !pickerValue.isEmpty, pickerValue != Self.passwordPickerValue else { return nil } + switch pickerValue { + case GoogleCloudSQLAuthFields.applicationDefault: + self = .googleApplicationDefault + case GoogleCloudSQLAuthFields.serviceAccount: + self = .googleServiceAccount + case Self.awsProfile.pickerValue: + self = .awsProfile + case Self.awsSSO.pickerValue: + self = .awsSSO + default: + // The Mac's credential resolver signs any other value with the access key. + self = .awsAccessKey + } + } + + var id: String { pickerValue } + + var pickerValue: String { + switch self { + case .awsAccessKey: "accessKey" + case .awsProfile: "profile" + case .awsSSO: "sso" + case .googleApplicationDefault: GoogleCloudSQLAuthFields.applicationDefault + case .googleServiceAccount: GoogleCloudSQLAuthFields.serviceAccount + } + } + + var title: String { + switch self { + case .awsAccessKey: String(localized: "AWS IAM (Access Key)") + case .awsProfile: String(localized: "AWS IAM (Profile)") + case .awsSSO: String(localized: "AWS IAM (SSO)") + case .googleApplicationDefault: String(localized: "Google Cloud IAM (Application Default)") + case .googleServiceAccount: String(localized: "Google Cloud IAM (Service Account)") + } + } + + var isAWS: Bool { + switch self { + case .awsAccessKey, .awsProfile, .awsSSO: true + case .googleApplicationDefault, .googleServiceAccount: false + } + } +} + +nonisolated extension DatabaseConnection { + var iamSignIn: IAMSignIn? { + guard let method = IAMSignIn(pickerValue: additionalFields[IAMSignIn.Field.method]), + IAMSignIn.methods(for: type).contains(method) else { return nil } + return method + } +} diff --git a/apple/TableProMobile/TableProMobile/SignIn/IAMSignInError.swift b/apple/TableProMobile/TableProMobile/SignIn/IAMSignInError.swift new file mode 100644 index 0000000000..e949c8e0c6 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SignIn/IAMSignInError.swift @@ -0,0 +1,40 @@ +import Foundation + +nonisolated enum IAMSignInError: Error, LocalizedError, Equatable, Sendable { + case accessKeyMissing + case profileNeedsDeviceKey(profile: String) + case applicationDefaultNeedsDeviceKey + case serviceAccountKeyMissing + case serviceAccountKeyIsMacFile + case ssoNotSetUp + case ssoSignInRequired + case encryptionNotEstablished + + var needsSignIn: Bool { + self == .ssoSignInRequired + } + + var errorDescription: String? { + switch self { + case .accessKeyMissing: + String(localized: "This connection signs in with an AWS access key, and this device does not have it.") + case .profileNeedsDeviceKey(let profile): + String( + format: String(localized: "This connection signs in with the AWS profile \"%@\", which is read from files on the Mac."), + profile + ) + case .applicationDefaultNeedsDeviceKey: + String(localized: "This connection signs in with the gcloud credentials on the Mac.") + case .serviceAccountKeyMissing: + String(localized: "This connection signs in with a Google Cloud service account, and this device does not have its key.") + case .serviceAccountKeyIsMacFile: + String(localized: "The service account key is a file on the Mac, which this device cannot read.") + case .ssoNotSetUp: + String(localized: "This connection signs in with IAM Identity Center, which is not set up on this device.") + case .ssoSignInRequired: + String(localized: "Sign in to IAM Identity Center to use this connection.") + case .encryptionNotEstablished: + String(localized: "The server did not encrypt the connection, so TablePro closed it. An IAM sign-in needs TLS.") + } + } +} diff --git a/apple/TableProMobile/TableProMobile/SignIn/SignInOffer.swift b/apple/TableProMobile/TableProMobile/SignIn/SignInOffer.swift new file mode 100644 index 0000000000..24ce9bdb4f --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SignIn/SignInOffer.swift @@ -0,0 +1,59 @@ +import Foundation +import TableProDatabase +import TableProModels +import UIKit + +struct SignInMethod { + let question: ConnectionPrompt + let signIn: @MainActor (_ prompts: ConnectionPromptQueue, _ generation: Int) async throws -> Void +} + +enum SignInOutcome { + case signedIn + case declined + case failed(Error) +} + +// A connect that failed only because a browser sign-in expired. The question and the code both +// belong to the attempt, so they go through its own queue. +enum SignInOffer { + static func method( + for error: Error, + connection: DatabaseConnection, + secureStore: any SecureStore + ) -> SignInMethod? { + if EntraSignIn.needsSignIn(error) { + return EntraSignIn.method(fields: connection.additionalFields) + } + if AWSSSOSignIn.needsSignIn(error), + let setup = AWSSSOSignIn.setup(for: connection.id, secureStore: secureStore) { + return AWSSSOSignIn.method(setup) + } + return nil + } + + // A failed sign-in is the error to show: the connect error only says a sign-in is needed. + static func offer(_ method: SignInMethod, prompts: ConnectionPromptQueue) async -> SignInOutcome { + let generation = prompts.generation + guard await prompts.ask(method.question) else { return .declined } + do { + try await method.signIn(prompts, generation) + return .signedIn + } catch { + return .failed(error) + } + } + + static func showCode(_ message: String, opening url: URL, prompts: ConnectionPromptQueue, generation: Int) { + prompts.notify( + ConnectionPrompt( + title: String(localized: "Finish Signing In"), + message: message, + confirmTitle: String(localized: "OK"), + style: .notice + ), + generation: generation + ) + UIApplication.shared.open(url) + } +} diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift index 55b85fa944..e93dc1987d 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift @@ -7,12 +7,14 @@ nonisolated struct ConnectionFormSecrets: Equatable, Sendable { var sshKeyPassphrase = "" var privateKey: String? var jumpHostCredentials: [UUID: JumpHostCredential] = [:] + var iam = ConnectionIAMSecrets() } nonisolated struct ConnectionFormSecretWrites: Equatable, Sendable { var password: String? var sshPassword: String? var sshKeyPassphrase: String? + var iam = ConnectionIAMSecretWrites() } nonisolated struct ConnectionFormSnapshot: Equatable, Sendable { @@ -60,7 +62,8 @@ extension ConnectionFormViewModel { sshPassword: sshEnabled ? Self.changedSecret(sshPassword, loaded: storedSecrets.sshPassword) : nil, sshKeyPassphrase: sshEnabled ? Self.changedSecret(sshKeyPassphrase, loaded: storedSecrets.sshKeyPassphrase) - : nil + : nil, + iam: iamSecretWrites ) } @@ -70,7 +73,8 @@ extension ConnectionFormViewModel { sshPassword: sshEnabled && !sshPassword.isEmpty ? sshPassword : storedSecrets.sshPassword, sshKeyPassphrase: sshEnabled && !sshKeyPassphrase.isEmpty ? sshKeyPassphrase : storedSecrets.sshKeyPassphrase, privateKey: pastedPrivateKey, - jumpHostCredentials: jumpHostCredentialsUnreadable ? storedSecrets.jumpHostCredentials : jumpHostCredentials + jumpHostCredentials: jumpHostCredentialsUnreadable ? storedSecrets.jumpHostCredentials : jumpHostCredentials, + iam: iamSecretsAfterSave ) } diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+IAM.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+IAM.swift new file mode 100644 index 0000000000..541666bd26 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+IAM.swift @@ -0,0 +1,268 @@ +import Foundation +import os +import TableProDatabase +import TableProGoogleCloud +import TableProModels +import TableProPluginKit + +extension ConnectionFormViewModel { + private static let iamLogger = Logger(subsystem: "com.TablePro", category: "ConnectionFormViewModel") + + var iamMethods: [IAMSignIn] { + IAMSignIn.methods(for: type) + } + + var offersIAM: Bool { + !iamMethods.isEmpty + } + + var signsInWithIAM: Bool { + offersIAM && iam.method != nil + } + + var iamAWSService: IAMSignIn.AWSService? { + IAMSignIn.engine(for: type)?.awsService + } + + // Like a tunnel turned off, fields typed under a method that is then turned off are not saved. + var iamOptions: ConnectionFormEdits.IAMOptions? { + guard offersIAM else { return nil } + let current = iam.options + guard current.method == nil, var kept = baseline?.edits.iam else { return current } + kept.method = nil + return kept + } + + // Secrets follow the password rule: an empty field keeps what is stored. The session token is + // optional, so clearing it deletes it, and the device item is written as the form holds it. + var iamSecretsAfterSave: ConnectionIAMSecrets { + let stored = storedSecrets.iam + let typed = iam.secrets + var result = stored + switch offersIAM ? iam.method : nil { + case .awsAccessKey: + if !typed.secretAccessKey.isEmpty { result.secretAccessKey = typed.secretAccessKey } + result.sessionToken = typed.sessionToken + case .googleServiceAccount: + if !typed.serviceAccountKey.isEmpty { result.serviceAccountKey = typed.serviceAccountKey } + case .awsProfile: + result.device.accessKeyId = typed.device.accessKeyId + result.device.secretAccessKey = typed.device.secretAccessKey + result.device.sessionToken = typed.device.sessionToken + case .awsSSO: + result.device.ssoStartUrl = typed.device.ssoStartUrl + result.device.ssoRegion = typed.device.ssoRegion + result.device.ssoAccountId = typed.device.ssoAccountId + result.device.ssoRoleName = typed.device.ssoRoleName + case .googleApplicationDefault: + result.device.serviceAccountKey = typed.device.serviceAccountKey + case nil: + break + } + return result + } + + var iamSecretWrites: ConnectionIAMSecretWrites { + let stored = storedSecrets.iam + let after = iamSecretsAfterSave + return ConnectionIAMSecretWrites( + secretAccessKey: after.secretAccessKey == stored.secretAccessKey ? nil : after.secretAccessKey, + sessionToken: after.sessionToken == stored.sessionToken ? nil : after.sessionToken, + serviceAccountKey: after.serviceAccountKey == stored.serviceAccountKey ? nil : after.serviceAccountKey, + device: after.device == stored.device ? nil : after.device + ) + } + + // Test Connection runs under its own id, so the secrets the mint reads move to that id. + func iamTestSecrets(for connectionId: UUID) -> [String: String] { + guard signsInWithIAM else { return [:] } + let values = iamSecretsAfterSave + var secrets: [String: String] = [:] + let typed: [(ConnectionSecretKind, String)] = [ + (.awsSecretAccessKey, values.secretAccessKey), + (.awsSessionToken, values.sessionToken), + (.gcpServiceAccountKey, values.serviceAccountKey) + ] + for (kind, value) in typed where !value.isEmpty { + secrets[kind.account(for: connectionId)] = value + } + let scratch = EphemeralSecureStore() + let deviceAccount = DeviceIAMCredentials.account(for: connectionId) + do { + try values.device.save(for: connectionId, to: scratch) + if let encoded = try scratch.retrieve(forKey: deviceAccount) { + secrets[deviceAccount] = encoded + } + } catch { + Self.iamLogger.error("Encoding the device sign-in settings failed: \(error.localizedDescription, privacy: .public)") + } + return secrets + } + + // MARK: - Service Account Key + + var activeServiceAccountKey: String { + switch iam.method { + case .googleServiceAccount: iam.secrets.serviceAccountKey + case .googleApplicationDefault: iam.secrets.device.serviceAccountKey + default: "" + } + } + + var serviceAccountKeyStatus: String { + guard !activeServiceAccountKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return String(localized: "Not set") + } + do { + _ = try GoogleServiceAccountKey.parse(fieldValue: activeServiceAccountKey, readFile: { _ in nil }) + return String(localized: "Saved") + } catch GoogleAuthError.credentialFileUnreadable { + return String(localized: "File on the Mac") + } catch { + return String(localized: "Not valid") + } + } + + static func serviceAccountKeyProblem(_ text: String) -> String? { + do { + _ = try GoogleServiceAccountKey.parse(fieldValue: text, readFile: { _ in nil }) + return nil + } catch GoogleAuthError.credentialFileUnreadable { + return String(localized: "Paste the contents of the JSON key. A file path cannot be read on this device.") + } catch GoogleAuthError.credentialIsPEM { + return String(localized: "Paste the whole JSON key, not the private key alone.") + } catch let error as GoogleAuthError { + return GoogleAuthErrorMessages.message(for: error) + } catch { + return error.localizedDescription + } + } + + func adoptServiceAccountKey(_ text: String) { + switch iam.method { + case .googleServiceAccount: iam.secrets.serviceAccountKey = text + case .googleApplicationDefault: iam.secrets.device.serviceAccountKey = text + default: break + } + } + + func removeDeviceServiceAccountKey() { + iam.secrets.device.serviceAccountKey = "" + } + + func importServiceAccountKeyFile(_ result: Result<[URL], any Error>) { + let url: URL + switch result { + case .success(let urls): + guard let first = urls.first else { return } + url = first + case .failure(let error): + iam.serviceAccountKeyFileError = error.localizedDescription + return + } + let didStart = url.startAccessingSecurityScopedResource() + defer { + if didStart { url.stopAccessingSecurityScopedResource() } + } + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + iam.serviceAccountKeyFileError = String(localized: "TablePro could not read that file.") + return + } + if let problem = Self.serviceAccountKeyProblem(text) { + iam.serviceAccountKeyFileError = problem + return + } + adoptServiceAccountKey(text) + } + + func dismissServiceAccountKeyFileError() { + iam.serviceAccountKeyFileError = nil + } + + // MARK: - IAM Identity Center + + var identityCenterSetup: AWSSSOSignIn.Setup? { + let startUrl = iam.secrets.device.ssoStartUrl.trimmingCharacters(in: .whitespacesAndNewlines) + let region = iam.secrets.device.ssoRegion.trimmingCharacters(in: .whitespacesAndNewlines) + guard !startUrl.isEmpty, !region.isEmpty else { return nil } + return AWSSSOSignIn.Setup(startUrl: startUrl, region: region) + } + + func requestIdentityCenterSignIn() { + iam.identityCenter.request = IdentityCenterSetupState.Request(kind: .signIn) + } + + func selectIdentityCenterAccount(_ accountId: String) { + guard accountId != iam.secrets.device.ssoAccountId else { return } + iam.secrets.device.ssoAccountId = accountId + iam.secrets.device.ssoRoleName = "" + iam.identityCenter.roles = [] + iam.identityCenter.request = accountId.isEmpty + ? nil + : IdentityCenterSetupState.Request(kind: .roles(accountId: accountId)) + } + + // The view runs this from `.task(id:)`, so a newer request or closing the form cancels it. + func runIdentityCenterRequest() async { + guard let request = iam.identityCenter.request else { return } + defer { + if iam.identityCenter.request == request { + iam.identityCenter.activity = .idle + iam.identityCenter.request = nil + } + } + iam.identityCenter.error = nil + do { + switch request.kind { + case .signIn: + try await signInToIdentityCenter() + case .roles(let accountId): + try await loadIdentityCenterRoles(accountId: accountId) + } + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + iam.identityCenter.error = error.localizedDescription + } + } + + private func signInToIdentityCenter() async throws { + guard let setup = identityCenterSetup else { return } + iam.identityCenter.activity = .signingIn + let tokens = AWSSSOTokenStore() + try await AWSSSOSignIn.method(setup, tokens: tokens).signIn(prompts, prompts.generation) + guard let token = tokens.token(startUrl: setup.startUrl) else { + throw AWSSSOLoginError.unexpectedResponse + } + let accounts = try await AWSSSOPortal.listAccounts(region: setup.region, accessToken: token.accessToken) + try Task.checkCancellation() + iam.identityCenter.accounts = accounts + let saved = iam.secrets.device.ssoAccountId + if !accounts.contains(where: { $0.accountId == saved }) { + iam.secrets.device.ssoAccountId = accounts.count == 1 ? accounts[0].accountId : "" + iam.secrets.device.ssoRoleName = "" + } + let accountId = iam.secrets.device.ssoAccountId + guard !accountId.isEmpty else { return } + try await loadIdentityCenterRoles(accountId: accountId) + } + + private func loadIdentityCenterRoles(accountId: String) async throws { + guard let setup = identityCenterSetup, + let token = AWSSSOTokenStore().token(startUrl: setup.startUrl) else { return } + iam.identityCenter.activity = .loadingRoles + let roles = try await AWSSSOPortal.listAccountRoles( + region: setup.region, + accessToken: token.accessToken, + accountId: accountId + ) + try Task.checkCancellation() + guard iam.secrets.device.ssoAccountId == accountId else { return } + iam.identityCenter.roles = roles + let saved = iam.secrets.device.ssoRoleName + if !roles.contains(where: { $0.roleName == saved }) { + iam.secrets.device.ssoRoleName = roles.count == 1 ? roles[0].roleName : "" + } + } +} diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 3df049d7e9..5a8ed88d10 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -65,6 +65,7 @@ final class ConnectionFormViewModel { var oracleSID = "" var oracleRole: OracleConnectionOptions.Role = .normal var oracleNetworkEncryption: OracleConnectionOptions.NetworkEncryption = .accepted + var iam = ConnectionIAMFormState() // Organization var groupId: UUID? @@ -156,6 +157,7 @@ final class ConnectionFormViewModel { oracleSID = conn.additionalFields[OracleConnectionOptions.AdditionalFieldKey.sid] ?? "" oracleRole = OracleConnectionOptions.role(from: conn.additionalFields) oracleNetworkEncryption = OracleConnectionOptions.networkEncryption(from: conn.additionalFields) + iam = ConnectionIAMFormState(connection: conn) sshEnabled = conn.sshEnabled groupId = conn.groupId tagId = conn.tagId @@ -237,7 +239,8 @@ final class ConnectionFormViewModel { safeModeLevel: safeModeLevel, sslMode: effectiveSSLMode, sshTunnel: sshTunnel, - oracle: oracleOptions + oracle: oracleOptions, + iam: iamOptions ) } @@ -315,7 +318,12 @@ final class ConnectionFormViewModel { sshKeyInputMode = .paste } loadJumpHostCredentials(for: conn, in: secureStore) - baseline?.secrets = snapshot.secrets + let iamSecrets = ConnectionIAMSecrets.stored(for: conn.id, in: secureStore) + iam.secrets = iamSecrets + storedSecrets.iam = iamSecrets + // Read first: `snapshot` reads `baseline`, which an in-place write would already hold. + let loaded = snapshot.secrets + baseline?.secrets = loaded } private func loadJumpHostCredentials(for connection: DatabaseConnection, in secureStore: any SecureStore) { @@ -351,6 +359,7 @@ final class ConnectionFormViewModel { private func onTypeChange(from oldType: DatabaseType) { guard oldType != type else { return } + iam.keepMethodOffered(for: type) updateDefaultPort() selectedFileURL = nil database = "" @@ -491,6 +500,7 @@ final class ConnectionFormViewModel { if !password.isEmpty { secrets[ConnectionSecretKind.password.account(for: connectionId)] = password } + secrets.merge(iamTestSecrets(for: connectionId)) { _, iamSecret in iamSecret } guard sshEnabled else { return secrets } if !sshPassword.isEmpty { secrets[ConnectionSecretKind.sshPassword.account(for: connectionId)] = sshPassword @@ -556,7 +566,8 @@ final class ConnectionFormViewModel { operation: "testConnection", databaseType: type, host: host, - sshEnabled: sshEnabled + sshEnabled: sshEnabled, + signsInWithIAM: testConn.iamSignIn != nil ) let classified = ErrorClassifier.classify(error, context: context) testResult = TestResult( @@ -717,6 +728,10 @@ final class ConnectionFormViewModel { } } + if !storedSecrets.iam.persist(writes.iam, for: connectionId, in: secureStore) { + storageFailed = true + } + do { try persistPrivateKey(secureStore: secureStore) } catch { diff --git a/apple/TableProMobile/TableProMobile/Views/Components/ErrorView.swift b/apple/TableProMobile/TableProMobile/Views/Components/ErrorView.swift index aac329bfdf..711cecf8fb 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/ErrorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/ErrorView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ErrorView: View { let error: AppError var onRetry: (() async -> Void)? + var onEditConnection: (() -> Void)? var body: some View { ContentUnavailableView { @@ -17,15 +18,26 @@ struct ErrorView: View { } } } actions: { - if let onRetry { - Button("Retry") { - Task { await onRetry() } + if error.opensEditor, let onEditConnection { + Button("Edit Connection", action: onEditConnection) + .buttonStyle(.borderedProminent) + if let onRetry { + retryButton(onRetry) + .buttonStyle(.bordered) } - .buttonStyle(.borderedProminent) + } else if let onRetry { + retryButton(onRetry) + .buttonStyle(.borderedProminent) } } } + private func retryButton(_ onRetry: @escaping () async -> Void) -> some View { + Button("Retry") { + Task { await onRetry() } + } + } + private var iconName: String { switch error.category { case .network: return "wifi.exclamationmark" diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift index 2b1aeb0c84..e351d7ccae 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -101,7 +101,11 @@ struct ConnectedView: View { statusScreen { connectingView } case .failed(let error): statusScreen { - ErrorView(error: error, onRetry: error.canRetry ? { await coordinator.connect() } : nil) + ErrorView( + error: error, + onRetry: error.canRetry ? { await coordinator.connect() } : nil, + onEditConnection: connection.isSample ? nil : { presenter.presentConnectionEditor(for: connection.id) } + ) } case .tabs: connectedContent(coordinator) diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionAuthenticationSection.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionAuthenticationSection.swift new file mode 100644 index 0000000000..a903bf8b36 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionAuthenticationSection.swift @@ -0,0 +1,234 @@ +import SwiftUI +import TableProPluginKit + +struct ConnectionAuthenticationSection: View { + @Bindable var viewModel: ConnectionFormViewModel + var onChooseKeyFile: () -> Void + var onPasteKey: () -> Void + + var body: some View { + Section { + Picker(String(localized: "Method"), selection: $viewModel.iam.method) { + Text("Password").tag(IAMSignIn?.none) + ForEach(viewModel.iamMethods) { method in + Text(method.title).tag(Optional(method)) + } + } + + if let method = viewModel.iam.method { + methodRows(method) + } + } header: { + Text("Authentication") + } footer: { + switch viewModel.iam.method { + case .awsAccessKey, .googleServiceAccount: + Text("Keys sync to your other devices when Sync Passwords is on.") + default: + EmptyView() + } + } + + switch viewModel.iam.method { + case .awsProfile: + deviceAccessKeySection + case .awsSSO: + identityCenterSection + case .googleApplicationDefault: + deviceServiceAccountKeySection + default: + EmptyView() + } + } + + @ViewBuilder + private func methodRows(_ method: IAMSignIn) -> some View { + if method.isAWS { + TextField("AWS Region", text: $viewModel.iam.region) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + if viewModel.iamAWSService == .elastiCache { + TextField("Cache Name / Replication Group ID", text: $viewModel.iam.replicationGroupId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + } else { + TextField("RDS Endpoint (optional)", text: $viewModel.iam.rdsEndpoint) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + } + } + + switch method { + case .awsAccessKey: + accessKeyFields( + accessKeyId: $viewModel.iam.accessKeyId, + secretAccessKey: $viewModel.iam.secrets.secretAccessKey, + sessionToken: $viewModel.iam.secrets.sessionToken + ) + case .awsProfile, .awsSSO: + TextField("Profile Name", text: $viewModel.iam.profileName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + case .googleServiceAccount: + serviceAccountKeyRow(removable: false) + case .googleApplicationDefault: + EmptyView() + } + } + + @ViewBuilder + private func accessKeyFields( + accessKeyId: Binding, + secretAccessKey: Binding, + sessionToken: Binding + ) -> some View { + TextField("Access Key ID", text: accessKeyId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + SecureField("Secret Access Key", text: secretAccessKey) + SecureField("Session Token (optional)", text: sessionToken) + } + + private func serviceAccountKeyRow(removable: Bool) -> some View { + Menu { + Button(String(localized: "Paste")) { onPasteKey() } + Button(String(localized: "Choose File")) { onChooseKeyFile() } + if removable, !viewModel.activeServiceAccountKey.isEmpty { + Button(String(localized: "Remove"), role: .destructive) { + viewModel.removeDeviceServiceAccountKey() + } + } + } label: { + HStack { + Text("Service Account Key") + Spacer() + Text(viewModel.serviceAccountKeyStatus) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - This Device + + private var deviceAccessKeySection: some View { + Section { + accessKeyFields( + accessKeyId: $viewModel.iam.secrets.device.accessKeyId, + secretAccessKey: $viewModel.iam.secrets.device.secretAccessKey, + sessionToken: $viewModel.iam.secrets.device.sessionToken + ) + } header: { + Text("Access Key for This Device") + } footer: { + Text("The profile is read from files on the Mac. A key added here signs in on this device only. The Mac's connection does not change.") + } + } + + private var deviceServiceAccountKeySection: some View { + Section { + serviceAccountKeyRow(removable: true) + } header: { + Text("Key for This Device") + } footer: { + Text("Application Default credentials are files on the Mac. A key added here signs in on this device only. The Mac's connection does not change.") + } + } + + private var identityCenterSection: some View { + Section { + TextField("Start URL", text: $viewModel.iam.secrets.device.ssoStartUrl) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + TextField("SSO Region", text: $viewModel.iam.secrets.device.ssoRegion) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + + Button { + viewModel.requestIdentityCenterSignIn() + } label: { + HStack { + Text("Sign In with IAM Identity Center") + if viewModel.iam.identityCenter.activity != .idle { + Spacer() + ProgressView() + } + } + } + .disabled(viewModel.identityCenterSetup == nil || viewModel.iam.identityCenter.activity == .signingIn) + + accountRows + + if let error = viewModel.iam.identityCenter.error { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + } + } header: { + Text("IAM Identity Center") + } footer: { + Text("These settings stay on this device. The Mac reads them from its AWS profile.") + } + } + + @ViewBuilder + private var accountRows: some View { + let device = viewModel.iam.secrets.device + let state = viewModel.iam.identityCenter + if state.accounts.isEmpty { + LabeledContent("Account") { + savedValue(device.ssoAccountId) + } + LabeledContent("Role") { + savedValue(device.ssoRoleName) + } + } else { + Picker(String(localized: "Account"), selection: accountSelection) { + Text("None").tag("") + if !device.ssoAccountId.isEmpty, !state.accounts.contains(where: { $0.accountId == device.ssoAccountId }) { + Text(device.ssoAccountId).tag(device.ssoAccountId) + } + ForEach(state.accounts) { account in + Text(accountTitle(account)).tag(account.accountId) + } + } + Picker(String(localized: "Role"), selection: $viewModel.iam.secrets.device.ssoRoleName) { + Text("None").tag("") + if !device.ssoRoleName.isEmpty, !state.roles.contains(where: { $0.roleName == device.ssoRoleName }) { + Text(device.ssoRoleName).tag(device.ssoRoleName) + } + ForEach(state.roles) { role in + Text(role.roleName).tag(role.roleName) + } + } + .disabled(device.ssoAccountId.isEmpty) + } + } + + private var accountSelection: Binding { + Binding( + get: { viewModel.iam.secrets.device.ssoAccountId }, + set: { viewModel.selectIdentityCenterAccount($0) } + ) + } + + @ViewBuilder + private func savedValue(_ value: String) -> some View { + if value.isEmpty { + Text("Not set") + } else { + Text(value) + } + } + + private func accountTitle(_ account: AWSSSOAccount) -> String { + guard let name = account.accountName, !name.isEmpty else { return account.accountId } + return "\(name) (\(account.accountId))" + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 34e58fc920..a7210ba48c 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -18,6 +18,7 @@ struct ConnectionFormView: View { @State private var hapticError = false @State private var pasteTarget: CertificateRole? @State private var showPKCS12Password = false + @State private var pastingServiceAccountKey = false @State private var pushedJumpHostId: UUID? var onSaved: (UUID) -> Void @@ -28,6 +29,7 @@ struct ConnectionFormView: View { case sshKey case certificate(CertificateRole) case pkcs12 + case serviceAccountKey var id: Int { hashValue } } @@ -76,6 +78,13 @@ struct ConnectionFormView: View { ) } + private var showServiceAccountKeyFileError: Binding { + Binding( + get: { viewModel.iam.serviceAccountKeyFileError != nil }, + set: { if !$0 { viewModel.dismissServiceAccountKeyFileError() } } + ) + } + var body: some View { @Bindable var viewModel = viewModel return NavigationStack { @@ -142,6 +151,9 @@ struct ConnectionFormView: View { viewModel.loadCertificateSummaries() await viewModel.loadStoredCredentials(secureStore: appState.secureStore) } + .task(id: viewModel.iam.identityCenter.request) { + await viewModel.runIdentityCenterRequest() + } .navigationTitle(viewModel.isEditing ? String(localized: "Edit Connection") : String(localized: "New Connection")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -168,6 +180,7 @@ struct ConnectionFormView: View { case .pkcs12: viewModel.stagePKCS12(result) showPKCS12Password = viewModel.pendingPKCS12 != nil + case .serviceAccountKey: viewModel.importServiceAccountKeyFile(result) case nil: break } } @@ -179,6 +192,9 @@ struct ConnectionFormView: View { .sheet(item: $pasteTarget) { role in CertificatePasteSheet(viewModel: viewModel, role: role) } + .sheet(isPresented: $pastingServiceAccountKey) { + ServiceAccountKeyPasteSheet(viewModel: viewModel) + } .alert("Certificate Password", isPresented: $showPKCS12Password) { SecureField("Password", text: $viewModel.pkcs12Password) Button("Import") { viewModel.importPKCS12() } @@ -213,6 +229,11 @@ struct ConnectionFormView: View { } message: { Text(viewModel.sshKeyFileError ?? "") } + .alert("Service Account Key", isPresented: showServiceAccountKeyFileError) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.iam.serviceAccountKeyFileError ?? "") + } .libraryWriteFailureAlert( viewModel.saveFailure, onDismiss: viewModel.dismissSaveFailure, @@ -419,7 +440,9 @@ struct ConnectionFormView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.asciiCapable) - SecureField("Password", text: $viewModel.password) + if !viewModel.signsInWithIAM { + SecureField("Password", text: $viewModel.password) + } } header: { Text("Server") } footer: { @@ -427,6 +450,13 @@ struct ConnectionFormView: View { Text("Username is for Redis 6 and later ACL users. Leave it empty for password-only servers.") } } + if viewModel.offersIAM { + ConnectionAuthenticationSection( + viewModel: viewModel, + onChooseKeyFile: { present(.serviceAccountKey) }, + onPasteKey: { pastingServiceAccountKey = true } + ) + } Section("Database") { TextField("Database Name", text: $viewModel.database) .textInputAutocapitalization(.never) @@ -604,6 +634,7 @@ struct ConnectionFormView: View { case .duckdbDatabase: return duckDBContentTypes case .certificate: return certificateContentTypes case .pkcs12: return pkcs12ContentTypes + case .serviceAccountKey: return [.json] default: return [.data] } } diff --git a/apple/TableProMobile/TableProMobile/Views/ServiceAccountKeyPasteSheet.swift b/apple/TableProMobile/TableProMobile/Views/ServiceAccountKeyPasteSheet.swift new file mode 100644 index 0000000000..06a5300a32 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/ServiceAccountKeyPasteSheet.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct ServiceAccountKeyPasteSheet: View { + @Environment(\.dismiss) private var dismiss + let viewModel: ConnectionFormViewModel + @State private var text = "" + @State private var problem: String? + + private var isEmpty: Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationStack { + Form { + Section { + TextEditor(text: $text) + .font(.system(.footnote, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .frame(minHeight: 220) + } footer: { + if let problem { + Text(problem) + .foregroundStyle(.red) + } else { + Text("Paste the whole JSON key from Google Cloud, including its braces.") + } + } + } + .navigationTitle(String(localized: "Service Account Key")) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: text) { + problem = nil + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Import", action: importKey) + .disabled(isEmpty) + } + } + } + } + + private func importKey() { + if let problem = ConnectionFormViewModel.serviceAccountKeyProblem(text) { + self.problem = problem + return + } + viewModel.adoptServiceAccountKey(text) + dismiss() + } +} diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelIAMTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelIAMTests.swift new file mode 100644 index 0000000000..185d6dd81b --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelIAMTests.swift @@ -0,0 +1,265 @@ +import Foundation +import TableProDatabase +import TableProModels +import Testing + +@testable import TableProMobile + +@MainActor +struct ConnectionFormViewModelIAMTests { + private static let rdsHost = "mydb.abc123.us-east-1.rds.amazonaws.com" + private static let serviceAccountJSON = #""" + {"type":"service_account","client_email":"reader@proj.iam.gserviceaccount.com","private_key":"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"} + """# + + private let fixture: AppStateFixture + + init() throws { + fixture = try AppStateFixture() + } + + private func rds(_ method: IAMSignIn, fields: [String: String] = [:]) -> DatabaseConnection { + DatabaseConnection( + name: "Orders", + type: .postgresql, + host: Self.rdsHost, + port: 5_432, + username: "iam_user", + database: "orders", + additionalFields: ["awsAuth": method.pickerValue, "awsRegion": "us-east-1"] + .merging(fields) { _, new in new } + ) + } + + private func loadedForm(_ connection: DatabaseConnection, store: MockSecureStore) async -> ConnectionFormViewModel { + let form = fixture.makeFormViewModel(editing: connection) + await form.loadStoredCredentials(secureStore: store) + return form + } + + private func makeAppState(holding connection: DatabaseConnection, store: MockSecureStore) -> AppState { + let state = fixture.makeState(syncEnabled: false, secureStore: store) + #expect(state.addConnection(connection)) + return state + } + + @Test("An edit with no change tests with the access key it loaded, under the test's own id") + func unchangedEditTestsWithLoadedAccessKey() async throws { + let connection = rds(.awsAccessKey, fields: ["awsAccessKeyId": "AKIAEXAMPLE"]) + let store = MockSecureStore() + store.seed(ConnectionSecretKind.awsSecretAccessKey.account(for: connection.id), "wJalrSECRET") + store.seed(ConnectionSecretKind.awsSessionToken.account(for: connection.id), "FQoGSESSION") + let form = await loadedForm(connection, store: store) + #expect(form.hasChanges == false) + + let tempId = UUID() + let secrets = EphemeralSecureStore(form.testSecrets(for: tempId)) + #expect(try secrets.retrieve(forKey: ConnectionSecretKind.awsSecretAccessKey.account(for: tempId)) == "wJalrSECRET") + #expect(try secrets.retrieve(forKey: ConnectionSecretKind.awsSessionToken.account(for: tempId)) == "FQoGSESSION") + + var testConnection = form.buildConnection() + testConnection.id = tempId + let token = try await IAMPasswordMint().password(for: testConnection, method: .awsAccessKey, secureStore: secrets) + #expect(token.hasPrefix("\(Self.rdsHost):5432/?Action=connect&DBUser=iam_user&")) + #expect(token.contains("X-Amz-Security-Token=FQoGSESSION")) + } + + @Test("The device's Identity Center settings reach Test Connection under its own id") + func deviceItemFollowsTheTest() async throws { + let connection = rds(.awsSSO, fields: ["awsProfileName": "prod"]) + let store = MockSecureStore() + let device = DeviceIAMCredentials( + ssoStartUrl: "https://example.awsapps.com/start", + ssoRegion: "us-east-1", + ssoAccountId: "111122223333", + ssoRoleName: "ReadOnly" + ) + try device.save(for: connection.id, to: store) + let form = await loadedForm(connection, store: store) + #expect(form.hasChanges == false) + + let tempId = UUID() + let secrets = EphemeralSecureStore(form.testSecrets(for: tempId)) + + #expect(try DeviceIAMCredentials.load(for: tempId, from: secrets) == device) + #expect(try secrets.retrieve(forKey: DeviceIAMCredentials.account(for: connection.id)) == nil) + } + + @Test("A synced service account key reads Saved and reaches Test Connection under its own id") + func serviceAccountKeyFollowsTheTest() async { + let connection = rds(.googleServiceAccount) + let store = MockSecureStore() + store.seed(ConnectionSecretKind.gcpServiceAccountKey.account(for: connection.id), Self.serviceAccountJSON) + let form = await loadedForm(connection, store: store) + + #expect(form.serviceAccountKeyStatus == String(localized: "Saved")) + let tempId = UUID() + let secrets = form.testSecrets(for: tempId) + #expect(secrets[ConnectionSecretKind.gcpServiceAccountKey.account(for: tempId)] == Self.serviceAccountJSON) + } + + @Test("A service account key that is a path on the Mac says so") + func macPathKeyIsNamed() async { + let connection = rds(.googleServiceAccount) + let store = MockSecureStore() + store.seed(ConnectionSecretKind.gcpServiceAccountKey.account(for: connection.id), "~/keys/reader.json") + let form = await loadedForm(connection, store: store) + + #expect(form.serviceAccountKeyStatus == String(localized: "File on the Mac")) + } + + @Test("Loading the IAM secrets is not a change, and editing one is") + func loadedIAMSecretsAreClean() async { + let connection = rds(.awsAccessKey, fields: ["awsAccessKeyId": "AKIAEXAMPLE"]) + let store = MockSecureStore() + store.seed(ConnectionSecretKind.awsSecretAccessKey.account(for: connection.id), "wJalrSECRET") + let form = await loadedForm(connection, store: store) + + #expect(form.iam.secrets.secretAccessKey == "wJalrSECRET") + #expect(form.hasChanges == false) + #expect(form.reconnectsAfterSave == false) + + form.iam.secrets.secretAccessKey = "" + #expect(form.hasChanges == false, "an empty field keeps the stored key") + + form.iam.secrets.secretAccessKey = "rotated" + #expect(form.reconnectsAfterSave) + #expect(form.secretWrites == ConnectionFormSecretWrites(iam: ConnectionIAMSecretWrites(secretAccessKey: "rotated"))) + } + + @Test("Clearing the session token deletes it, and the secret access key stays") + func clearedSessionTokenIsDeleted() async throws { + let connection = rds(.awsAccessKey, fields: ["awsAccessKeyId": "AKIAEXAMPLE"]) + let store = MockSecureStore() + let secretAccount = ConnectionSecretKind.awsSecretAccessKey.account(for: connection.id) + let tokenAccount = ConnectionSecretKind.awsSessionToken.account(for: connection.id) + store.seed(secretAccount, "wJalrSECRET") + store.seed(tokenAccount, "FQoGSESSION") + let form = await loadedForm(connection, store: store) + + form.iam.secrets.sessionToken = "" + let appState = makeAppState(holding: connection, store: store) + _ = try #require(await form.save(appState: appState, secureStore: store)) + + #expect(try store.retrieve(forKey: tokenAccount) == nil) + #expect(try store.retrieve(forKey: secretAccount) == "wJalrSECRET") + #expect(form.hasChanges == false) + } + + @Test("Saving a rename writes no IAM secret back, so a key changed on the Mac survives") + func renameLeavesIAMSecretsAlone() async throws { + let connection = rds(.awsAccessKey, fields: ["awsAccessKeyId": "AKIAEXAMPLE"]) + let store = MockSecureStore() + let secretAccount = ConnectionSecretKind.awsSecretAccessKey.account(for: connection.id) + store.seed(secretAccount, "wJalrSECRET") + let form = await loadedForm(connection, store: store) + + store.seed(secretAccount, "rotated-on-mac") + form.name = "Renamed" + #expect(form.secretWrites == ConnectionFormSecretWrites()) + let appState = makeAppState(holding: connection, store: store) + _ = try #require(await form.save(appState: appState, secureStore: store)) + + #expect(try store.retrieve(forKey: secretAccount) == "rotated-on-mac") + } + + @Test("A Profile device key is saved in the device item and never in the synced fields") + func profileDeviceKeyStaysOnTheDevice() async throws { + let connection = rds(.awsProfile, fields: ["awsProfileName": "prod"]) + let store = MockSecureStore() + let form = await loadedForm(connection, store: store) + form.iam.secrets.device.accessKeyId = "AKIADEVICE" + form.iam.secrets.device.secretAccessKey = "device-secret" + #expect(form.reconnectsAfterSave) + + let appState = makeAppState(holding: connection, store: store) + _ = try #require(await form.save(appState: appState, secureStore: store)) + + let device = try DeviceIAMCredentials.load(for: connection.id, from: store) + #expect(device.accessKeyId == "AKIADEVICE") + #expect(device.secretAccessKey == "device-secret") + let saved = appState.connections.first { $0.id == connection.id } + #expect(saved?.additionalFields == connection.additionalFields) + #expect(form.hasChanges == false) + } + + @Test("A region edit keeps an RDS Endpoint the Mac changed while the form was open") + func regionEditKeepsEndpointChangedElsewhere() { + let connection = rds(.awsAccessKey, fields: ["awsAccessKeyId": "AKIAEXAMPLE"]) + let form = fixture.makeFormViewModel(editing: connection) + var current = connection + current.additionalFields["awsRDSEndpoint"] = "proxy.example.com:5432" + #expect(form.applyingEdits(to: current) == current) + + form.iam.region = "eu-west-1" + let saved = form.applyingEdits(to: current) + + #expect(saved.additionalFields["awsRegion"] == "eu-west-1") + #expect(saved.additionalFields["awsRDSEndpoint"] == "proxy.example.com:5432") + #expect(saved.additionalFields["awsAccessKeyId"] == "AKIAEXAMPLE") + } + + @Test("A new connection stores no method until one is picked, and turning IAM off writes Password") + func methodIsWrittenOnlyWhenPicked() { + let fresh = fixture.makeFormViewModel() + fresh.type = .postgresql + #expect(fresh.buildConnection().additionalFields.isEmpty) + + fresh.iam.method = .awsAccessKey + fresh.iam.accessKeyId = "AKIAEXAMPLE" + #expect(fresh.signsInWithIAM) + #expect(fresh.buildConnection().additionalFields == ["awsAuth": "accessKey", "awsAccessKeyId": "AKIAEXAMPLE"]) + + let connection = rds(.awsAccessKey) + let editing = fixture.makeFormViewModel(editing: connection) + editing.iam.method = nil + + #expect(editing.signsInWithIAM == false) + #expect(editing.applyingEdits(to: connection).additionalFields["awsAuth"] == "off") + } + + @Test("An engine that does not offer the method turns it off, and one without IAM has no section") + func typeChangeDropsUnofferedMethod() { + let form = fixture.makeFormViewModel() + form.iam.method = .googleServiceAccount + form.type = .mariadb + #expect(form.iam.method == nil) + + form.iam.method = .awsAccessKey + form.type = .redis + #expect(form.iam.method == .awsAccessKey) + #expect(form.iamAWSService == .elastiCache) + + form.type = .mssql + #expect(form.offersIAM == false) + #expect(form.iam.method == nil) + #expect(form.edits.iam == nil) + } + + @Test("Fields typed under a method that is then turned off are not a change") + func discardedMethodIsClean() { + let form = fixture.makeFormViewModel() + form.iam.method = .awsAccessKey + form.iam.accessKeyId = "AKIAEXAMPLE" + form.iam.secrets.secretAccessKey = "secret" + #expect(form.hasChanges) + + form.iam.method = nil + #expect(form.hasChanges == false) + #expect(form.testSecrets(for: UUID()).isEmpty) + } + + @Test("A pasted key with a byte order mark is taken, while a private key alone or a Mac path is refused") + func pastedKeyValidation() { + #expect(ConnectionFormViewModel.serviceAccountKeyProblem("\u{FEFF}\n" + Self.serviceAccountJSON) == nil) + #expect(ConnectionFormViewModel.serviceAccountKeyProblem("-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----") != nil) + #expect(ConnectionFormViewModel.serviceAccountKeyProblem("~/keys/reader.json") != nil) + + let form = fixture.makeFormViewModel() + form.iam.method = .googleApplicationDefault + form.adoptServiceAccountKey(Self.serviceAccountJSON) + + #expect(form.iam.secrets.device.serviceAccountKey == Self.serviceAccountJSON) + #expect(form.iam.secrets.serviceAccountKey.isEmpty) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift index e5183e2b77..66037eb67b 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift @@ -68,13 +68,41 @@ struct ConnectionSecretsTests { "com.TablePro.sshpassword.", "com.TablePro.keypassphrase.", "com.TablePro.sshkeydata.", - "com.TablePro.jumphostkeys." + "com.TablePro.jumphostkeys.", + "com.TablePro.plugin.awsSecretAccessKey.", + "com.TablePro.plugin.awsSessionToken.", + "com.TablePro.plugin.gcpServiceAccountKey.", + "com.TablePro.deviceiam." ]) let id = UUID() #expect(ConnectionSecretKind.sshPrivateKey.account(for: id) == "com.TablePro.sshkeydata.\(id.uuidString)") + #expect( + ConnectionSecretKind.awsSecretAccessKey.account(for: id) + == "com.TablePro.plugin.awsSecretAccessKey.\(id.uuidString)" + ) } - @Test("The orphan sweep takes the passwords of a connection that is gone and nothing else") + @Test("A device-only sign-in key never syncs, whatever Sync Passwords says") + func deviceIAMNeverSyncs() { + let id = UUID() + + #expect(!KeychainSecureStore.synchronizes(ConnectionSecretKind.deviceIAM.account(for: id), syncsPasswords: true)) + #expect(!KeychainSecureStore.synchronizes(ConnectionSecretKind.deviceIAM.account(for: id), syncsPasswords: false)) + #expect(ConnectionSecretKind.allCases.filter { $0.isDeviceOnly } == [.deviceIAM]) + } + + @Test("A connection's own secrets follow Sync Passwords, the Mac's IAM keys included") + func ownSecretsFollowSyncPasswords() { + let id = UUID() + let synced = ConnectionSecretKind.allCases.filter { !$0.isDeviceOnly } + + for kind in synced { + #expect(KeychainSecureStore.synchronizes(kind.account(for: id), syncsPasswords: true)) + #expect(!KeychainSecureStore.synchronizes(kind.account(for: id), syncsPasswords: false)) + } + } + + @Test("The orphan sweep takes the secrets of a connection that is gone and nothing else") func sweepTakesOrphanedPasswordsOnly() { let kept = UUID() let removed = UUID() @@ -86,7 +114,11 @@ struct ConnectionSecretsTests { #expect(Set(orphaned) == [ ConnectionSecretKind.password.account(for: removed), ConnectionSecretKind.sshPassword.account(for: removed), - ConnectionSecretKind.keyPassphrase.account(for: removed) + ConnectionSecretKind.keyPassphrase.account(for: removed), + ConnectionSecretKind.awsSecretAccessKey.account(for: removed), + ConnectionSecretKind.awsSessionToken.account(for: removed), + ConnectionSecretKind.gcpServiceAccountKey.account(for: removed), + ConnectionSecretKind.deviceIAM.account(for: removed) ]) } diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift index 56520ca230..b07fcbca72 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift @@ -116,4 +116,20 @@ struct DriverSSLConfigurationTests { #expect(absent.existingClientKeyPath == nil) #expect(DriverSSLConfiguration(mode: .require).existingClientCertificatePath == nil) } + + @Test("A configuration read from the connection names no server, and materialized certificates keep the name") + func serverNameSurvivesMaterializing() { + #expect(DriverSSLConfiguration(sslEnabled: true, configuration: nil).serverName == nil) + #expect(DriverSSLConfiguration(sslEnabled: true, configuration: SSLConfiguration(mode: .verifyFull)).serverName == nil) + + var ssl = DriverSSLConfiguration(mode: .verifyFull) + ssl.serverName = "db.example.com" + let materialized = ssl.applying(MaterializedCertificates( + caCertificatePath: "/tmp/ca.pem", + clientCertificatePath: nil, + clientKeyPath: nil + )) + #expect(materialized.serverName == "db.example.com") + #expect(materialized.caCertificatePath == "/tmp/ca.pem") + } } diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTokenSignInTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTokenSignInTests.swift new file mode 100644 index 0000000000..fbe376735b --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTokenSignInTests.swift @@ -0,0 +1,387 @@ +import CMariaDB +import CRedis +import Darwin +import Foundation +@testable import TableProMobile +import TableProModels +import TableProTLSTestFixtures +import Testing + +@Suite(.timeLimit(.minutes(1))) +struct MySQLTokenSignInTests { + private static let token = "signed-token" + + @Test("A token is never sent to a server that does not offer TLS, even one that asks for it in cleartext") + func tokenNeverReachesAPlaintextServer() async throws { + let server = try FakeMySQLServer(offersTLS: false, asksForCleartext: true) + defer { server.close() } + async let session = server.serveOneSession() + let driver = tokenDriver(port: server.port) + + let error = await #expect(throws: IAMSignInError.self) { try await driver.connect() } + + #expect(error == .encryptionNotEstablished) + let served = await session + #expect(!served.sawInPlaintext(Self.token)) + #expect(!served.loggedIn) + } + + @Test("A token session answers the cleartext request inside TLS, without a CA to check the certificate against") + func tokenTravelsInsideTLS() async throws { + let server = try FakeMySQLServer(offersTLS: true, asksForCleartext: true) + defer { server.close() } + async let session = server.serveOneSession() + let driver = tokenDriver(port: server.port) + + try await driver.connect() + try await driver.disconnect() + + let served = await session + #expect(served.encrypted) + #expect(served.cleartextPassword == Self.token) + #expect(!served.sawInPlaintext(Self.token)) + #expect(served.loggedIn) + #expect(served.quit) + } + + @Test("Require alone still accepts a server that never offers TLS; only a token session refuses it") + func requireAloneFallsBackToPlaintext() async throws { + let server = try FakeMySQLServer(offersTLS: false, asksForCleartext: false) + defer { server.close() } + async let session = server.serveOneSession() + let driver = MySQLDriver( + host: "127.0.0.1", + port: server.port, + user: "app", + password: "secret", + database: "", + ssl: DriverSSLConfiguration(mode: .require) + ) + + try await driver.connect() + try await driver.disconnect() + + let served = await session + #expect(!served.encrypted) + #expect(served.loggedIn) + #expect(!served.queries.isEmpty) + #expect(served.quit) + } + + @Test("Only a token session refused for a server without TLS is an encryption failure") + func connectFailure() { + let sslError = UInt32(CR_SSL_CONNECTION_ERROR) + let offersTLS = UInt(CLIENT_SSL) + + #expect( + MySQLTokenSignIn.connectFailure(signsInWithToken: true, errorCode: sslError, serverCapabilities: 0) + == .encryptionNotEstablished + ) + #expect( + MySQLTokenSignIn.connectFailure(signsInWithToken: true, errorCode: sslError, serverCapabilities: offersTLS) + == nil + ) + #expect( + MySQLTokenSignIn.connectFailure(signsInWithToken: false, errorCode: sslError, serverCapabilities: 0) == nil + ) + #expect( + MySQLTokenSignIn.connectFailure( + signsInWithToken: true, + errorCode: UInt32(CR_CONN_HOST_ERROR), + serverCapabilities: 0 + ) == nil + ) + } + + private func tokenDriver(port: Int) -> MySQLDriver { + MySQLDriver( + host: "127.0.0.1", + port: port, + user: "iam_user", + password: Self.token, + database: "", + ssl: DriverSSLConfiguration(mode: .require), + signsInWithToken: true + ) + } +} + +// One loopback session that accepts any login. With `offersTLS` it upgrades after the client's SSL +// request; with `asksForCleartext` it answers the login with a switch to mysql_clear_password, as RDS does. +private final class FakeMySQLServer: @unchecked Sendable { + struct SetupFailure: Error {} + + struct Session: Sendable { + var plaintext: [UInt8] = [] + var encrypted = false + var cleartextPassword: String? + var loggedIn = false + var queries: [String] = [] + var quit = false + + func sawInPlaintext(_ secret: String) -> Bool { + let needle = Array(secret.utf8) + guard !needle.isEmpty, plaintext.count >= needle.count else { return false } + return (0...(plaintext.count - needle.count)).contains { start in + plaintext[start..<(start + needle.count)].elementsEqual(needle) + } + } + } + + private struct Packet { + let sequence: UInt8 + let payload: [UInt8] + } + + private enum Capability { + static let longPassword: UInt32 = 0x1 + static let connectWithDB: UInt32 = 0x8 + static let protocol41: UInt32 = 0x200 + static let ssl: UInt32 = 0x800 + static let transactions: UInt32 = 0x2000 + static let secureConnection: UInt32 = 0x8000 + static let multiResults: UInt32 = 0x2_0000 + static let pluginAuth: UInt32 = 0x8_0000 + } + + private static let comQuit: UInt8 = 0x01 + private static let comQuery: UInt8 = 0x03 + private static let okPacket: [UInt8] = [0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00] + private static let sslRequestLength = 32 + private static let switchToCleartext: [UInt8] = [0xFE] + Array("mysql_clear_password".utf8) + [0] + + let port: Int + private let listener: Int32 + private let offersTLS: Bool + private let asksForCleartext: Bool + private let fixtures: URL? + + init(offersTLS: Bool, asksForCleartext: Bool) throws { + self.offersTLS = offersTLS + self.asksForCleartext = asksForCleartext + if offersTLS { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("MySQLTokenSignInTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try TLSTestFixtures.data(TLSTestFixtures.serverCertificate) + .write(to: directory.appendingPathComponent("server.pem")) + try TLSTestFixtures.data(TLSTestFixtures.serverKey) + .write(to: directory.appendingPathComponent("server.key")) + fixtures = directory + } else { + fixtures = nil + } + + let socketFD = socket(AF_INET, SOCK_STREAM, 0) + guard socketFD >= 0 else { throw SetupFailure() } + + var option: Int32 = 1 + setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &option, socklen_t(MemoryLayout.size)) + + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(socketFD, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, Darwin.listen(socketFD, 4) == 0 else { + Darwin.close(socketFD) + throw SetupFailure() + } + + var boundAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &boundAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(socketFD, $0, &length) + } + } + guard named == 0 else { + Darwin.close(socketFD) + throw SetupFailure() + } + + listener = socketFD + port = Int(boundAddress.sin_port.bigEndian) + } + + func close() { + Darwin.close(listener) + if let fixtures { + try? FileManager.default.removeItem(at: fixtures) + } + } + + func serveOneSession() async -> Session { + await withCheckedContinuation { continuation in + DispatchQueue(label: "com.TablePro.tests.fake-mysql").async { + continuation.resume(returning: self.runSession()) + } + } + } + + private func runSession() -> Session { + var session = Session() + guard let client = acceptClient() else { return session } + let channel = ServerChannel(socket: client) + serve(on: channel, into: &session) + session.plaintext = channel.plaintext + channel.close() + return session + } + + private func serve(on channel: ServerChannel, into session: inout Session) { + guard channel.send(handshake, sequence: 0), var login = channel.readPacket() else { return } + + if offersTLS, login.payload.count == Self.sslRequestLength { + guard let fixtures, + channel.startTLS( + certificatePath: fixtures.appendingPathComponent("server.pem").path, + keyPath: fixtures.appendingPathComponent("server.key").path + ), + let encryptedLogin = channel.readPacket() + else { return } + session.encrypted = true + login = encryptedLogin + } + + var sequence = login.sequence + if asksForCleartext { + guard channel.send(Self.switchToCleartext, sequence: sequence &+ 1), + let answer = channel.readPacket() + else { return } + session.cleartextPassword = String(decoding: answer.payload.prefix { $0 != 0 }, as: UTF8.self) + sequence = answer.sequence + } + guard channel.send(Self.okPacket, sequence: sequence &+ 1) else { return } + session.loggedIn = true + + while let command = channel.readPacket() { + if command.payload.first == Self.comQuit { + session.quit = true + return + } + if command.payload.first == Self.comQuery { + session.queries.append(String(decoding: command.payload.dropFirst(), as: UTF8.self)) + } + guard channel.send(Self.okPacket, sequence: command.sequence &+ 1) else { return } + } + } + + private var handshake: [UInt8] { + var capabilities = Capability.longPassword | Capability.connectWithDB | Capability.protocol41 + | Capability.transactions | Capability.secureConnection | Capability.multiResults | Capability.pluginAuth + if offersTLS { + capabilities |= Capability.ssl + } + var payload: [UInt8] = [10] + payload += Array("8.0.36".utf8) + [0] + payload += [1, 0, 0, 0] + payload += Array("abcdefgh".utf8) + [0] + payload += [UInt8(capabilities & 0xFF), UInt8((capabilities >> 8) & 0xFF)] + payload += [45, 0x02, 0x00] + payload += [UInt8((capabilities >> 16) & 0xFF), UInt8((capabilities >> 24) & 0xFF)] + payload += [21] + [UInt8](repeating: 0, count: 10) + payload += Array("ijklmnopqrst".utf8) + [0] + payload += Array("mysql_native_password".utf8) + [0] + return payload + } + + private func acceptClient() -> Int32? { + var pollFD = pollfd(fd: listener, events: Int16(POLLIN), revents: 0) + guard poll(&pollFD, 1, ServerChannel.pollMilliseconds) > 0 else { return nil } + let client = accept(listener, nil, nil) + guard client >= 0 else { return nil } + var noSignal: Int32 = 1 + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + var timeout = timeval(tv_sec: 10, tv_usec: 0) + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + return client + } + + // Reads and writes MySQL packets on the socket, or inside TLS once `startTLS` succeeds. + private final class ServerChannel { + static let pollMilliseconds: Int32 = 10_000 + + private(set) var plaintext: [UInt8] = [] + private let socket: Int32 + private var context: OpaquePointer? + private var ssl: OpaquePointer? + private var encrypted = false + + init(socket: Int32) { + self.socket = socket + } + + func startTLS(certificatePath: String, keyPath: String) -> Bool { + guard let context = SSL_CTX_new(TLS_server_method()) else { return false } + self.context = context + guard SSL_CTX_use_certificate_file(context, certificatePath, SSL_FILETYPE_PEM) == 1, + SSL_CTX_use_PrivateKey_file(context, keyPath, SSL_FILETYPE_PEM) == 1, + let ssl = SSL_new(context) else { return false } + self.ssl = ssl + SSL_set_fd(ssl, socket) + encrypted = SSL_accept(ssl) == 1 + return encrypted + } + + func close() { + if let ssl { + if encrypted { SSL_shutdown(ssl) } + SSL_free(ssl) + } + if let context { SSL_CTX_free(context) } + Darwin.close(socket) + } + + func send(_ payload: [UInt8], sequence: UInt8) -> Bool { + let length = payload.count + let bytes = [UInt8(length & 0xFF), UInt8((length >> 8) & 0xFF), UInt8((length >> 16) & 0xFF), sequence] + + payload + var offset = 0 + while offset < bytes.count { + let sent = bytes[offset...].withUnsafeBytes { buffer -> Int in + if encrypted, let ssl { + return Int(SSL_write(ssl, buffer.baseAddress, Int32(buffer.count))) + } + return Darwin.send(socket, buffer.baseAddress, buffer.count, 0) + } + guard sent > 0 else { return false } + offset += sent + } + return true + } + + func readPacket() -> Packet? { + guard let header = receive(4) else { return nil } + let length = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16 + guard let payload = length == 0 ? [] : receive(length) else { return nil } + return Packet(sequence: header[3], payload: payload) + } + + private func receive(_ count: Int) -> [UInt8]? { + var bytes: [UInt8] = [] + while bytes.count < count { + var chunk = [UInt8](repeating: 0, count: count - bytes.count) + let received: Int + if encrypted, let ssl { + received = Int(SSL_read(ssl, &chunk, Int32(chunk.count))) + } else { + var pollFD = pollfd(fd: socket, events: Int16(POLLIN), revents: 0) + guard poll(&pollFD, 1, Self.pollMilliseconds) > 0 else { return nil } + received = recv(socket, &chunk, chunk.count, 0) + } + guard received > 0 else { return nil } + bytes += chunk.prefix(received) + } + if !encrypted { + plaintext += bytes + } + return bytes + } + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/PostgreSQLConnectionStringTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/PostgreSQLConnectionStringTests.swift index a82e087ba7..41686df294 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/PostgreSQLConnectionStringTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/PostgreSQLConnectionStringTests.swift @@ -96,4 +96,31 @@ struct PostgreSQLConnectionStringTests { #expect(connStr.contains("user='o\\'brien'")) #expect(connStr.contains("password='back\\\\slash'")) } + + @Test("Through a tunnel, libpq checks the certificate against the server name and dials the loopback") + func tunneledConnectionNamesTheServer() { + let connStr = PostgreSQLConnectionString.build( + host: "127.0.0.1", + port: 49_152, + database: "app", + user: "iam_user", + password: "signed-token", + ssl: DriverSSLConfiguration(mode: .verifyFull, serverName: "mydb.abc123.us-east-1.rds.amazonaws.com") + ) + + #expect(connStr.contains("host='mydb.abc123.us-east-1.rds.amazonaws.com'")) + #expect(connStr.contains("hostaddr='127.0.0.1'")) + #expect(!connStr.contains("host='127.0.0.1'")) + #expect(connStr.contains("port='49152'")) + } + + @Test("No server name, the same name, or a dial host that is not an address sends host alone") + func hostaddrOnlyForAnAddress() { + #expect(!build(ssl: .disabled).contains("hostaddr")) + #expect(!build(ssl: DriverSSLConfiguration(mode: .require, serverName: "db.example.com")).contains("hostaddr")) + + let named = build(ssl: DriverSSLConfiguration(mode: .verifyFull, serverName: "other.example.com")) + #expect(named.contains("host='db.example.com'")) + #expect(!named.contains("hostaddr")) + } } diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/RedisTLSOptionsMappingTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/RedisTLSOptionsMappingTests.swift index 3f2d79f537..7696e51c6b 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/RedisTLSOptionsMappingTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/RedisTLSOptionsMappingTests.swift @@ -26,4 +26,12 @@ struct RedisTLSOptionsMappingTests { #expect(options.verifiesCertificate == false) #expect(options.checksHostname == false) } + + @Test("Through a tunnel, SNI and the hostname check use the server name, not the loopback") + func tunneledConnectionUsesTheServerName() { + let ssl = DriverSSLConfiguration(mode: .verifyFull, serverName: "cache.abc123.use1.cache.amazonaws.com") + let options = RedisTLSOptions(ssl: ssl, host: "127.0.0.1") + #expect(options.serverName == "cache.abc123.use1.cache.amazonaws.com") + #expect(options.expectedHost == "cache.abc123.use1.cache.amazonaws.com") + } } diff --git a/apple/TableProMobile/TableProMobileTests/SignIn/IAMErrorClassifierTests.swift b/apple/TableProMobile/TableProMobileTests/SignIn/IAMErrorClassifierTests.swift new file mode 100644 index 0000000000..deefd96c8f --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SignIn/IAMErrorClassifierTests.swift @@ -0,0 +1,164 @@ +import Foundation +import TableProGoogleCloud +@testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing + +struct IAMErrorClassifierTests { + private func classify(_ error: Error, operation: String = "connect", signsInWithIAM: Bool = false) -> AppError { + ErrorClassifier.classify( + error, + context: ErrorContext( + operation: operation, + databaseType: .postgresql, + host: "ssh-tunnel.example.com", + sshEnabled: true, + signsInWithIAM: signsInWithIAM + ) + ) + } + + @Test("A missing access key says which key and opens the connection, not a password check") + func accessKeyMissing() { + let error = classify(IAMSignInError.accessKeyMissing) + + #expect(error.category == .config) + #expect(error.title == String(localized: "AWS Access Key Needed")) + #expect(error.message == IAMSignInError.accessKeyMissing.localizedDescription) + #expect(error.opensEditor) + } + + @Test("The PluginKit missing-key error reads as the same problem, though its message says authentication") + func pluginKitMissingAccessKey() { + let error = classify(AWSAuthError.missingAccessKey) + + #expect(error.title == String(localized: "AWS Access Key Needed")) + #expect(error.category == .config) + #expect(error.opensEditor) + } + + @Test("A Profile connection names its profile and asks for a key on this device") + func profileNeedsDeviceKey() { + let error = classify(IAMSignInError.profileNeedsDeviceKey(profile: "analytics")) + + #expect(error.category == .config) + #expect(error.title == String(localized: "AWS Profile Is on the Mac")) + #expect(error.message.contains("analytics")) + #expect(error.opensEditor) + } + + @Test("Application Default and a Mac key path each point at the key this device needs") + func googleKeysOnTheMac() { + let applicationDefault = classify(IAMSignInError.applicationDefaultNeedsDeviceKey) + let macFile = classify(IAMSignInError.serviceAccountKeyIsMacFile) + let missing = classify(IAMSignInError.serviceAccountKeyMissing) + + #expect(applicationDefault.title == String(localized: "Google Sign-In Is on the Mac")) + #expect(macFile.title == String(localized: "Service Account Key Is on the Mac")) + #expect(missing.title == String(localized: "Service Account Key Needed")) + #expect([applicationDefault, macFile, missing].allSatisfy { $0.opensEditor }) + } + + @Test("A host whose name says tunnel is still an AWS setting, not an SSH failure") + func hostWordsAreNotRead() { + let error = classify(AWSAuthError.regionUnknown(host: "ssh-tunnel.example.com")) + + #expect(error.category == .config) + #expect(error.title == String(localized: "AWS Region Needed")) + #expect(error.opensEditor) + } + + @Test("IAM Identity Center not set up opens the connection; an expired sign-in asks for Retry") + func identityCenterStates() { + let notSetUp = classify(IAMSignInError.ssoNotSetUp) + let expired = classify(IAMSignInError.ssoSignInRequired) + + #expect(notSetUp.title == String(localized: "IAM Identity Center Not Set Up")) + #expect(notSetUp.opensEditor) + #expect(expired.category == .auth) + #expect(expired.title == String(localized: "AWS SSO Sign-In Required")) + #expect(expired.recovery == String(localized: "Tap Retry to sign in to IAM Identity Center.")) + #expect(!expired.opensEditor) + } + + @Test("Test Connection's result never points at a Retry button it does not have") + func testConnectionHasNoRetry() { + let signInErrors: [Error] = [ + IAMSignInError.ssoSignInRequired, + AWSSSOError.sessionUnauthorized(profile: "default"), + AWSSSOLoginError.accessDenied, + AWSSSOLoginError.authorizationTimedOut, + EntraOAuthError.signInRequired, + EntraOAuthError.accessDenied, + EntraOAuthError.authorizationTimedOut + ] + + for error in signInErrors { + #expect(classify(error).recovery?.hasPrefix("Tap Retry") == true) + #expect(classify(error, operation: "testConnection").recovery == nil) + } + } + + @Test("An expired portal session never tells a phone to run the AWS CLI") + func portalSessionExpired() { + let error = classify(AWSSSOError.sessionUnauthorized(profile: "default")) + + #expect(error.title == String(localized: "AWS SSO Sign-In Required")) + #expect(!error.message.contains("aws sso login")) + } + + @Test("A denied or timed-out browser sign-in says so instead of asking for a password") + func browserSignInOutcomes() { + let denied = classify(AWSSSOLoginError.accessDenied) + let timedOut = classify(AWSSSOLoginError.authorizationTimedOut) + let entraDenied = classify(EntraOAuthError.accessDenied) + let entraTimedOut = classify(EntraOAuthError.authorizationTimedOut) + + #expect(denied.title == String(localized: "Sign-In Denied")) + #expect(entraDenied.title == String(localized: "Sign-In Denied")) + #expect(timedOut.title == String(localized: "Sign-In Timed Out")) + #expect(entraTimedOut.title == String(localized: "Sign-In Timed Out")) + #expect(denied.message == AWSSSOLoginError.accessDenied.localizedDescription) + #expect(entraTimedOut.message == EntraOAuthError.authorizationTimedOut.localizedDescription) + } + + @Test("A Google key error shows the Google message, which the error itself does not carry") + func googleMessages() { + let pem = classify(GoogleAuthError.credentialIsPEM) + let offline = classify(GoogleAuthError.transport("offline")) + + #expect(pem.title == String(localized: "Service Account Key Invalid")) + #expect(pem.message == GoogleAuthErrorMessages.message(for: .credentialIsPEM)) + #expect(pem.opensEditor) + #expect(offline.category == .network) + #expect(offline.message == GoogleAuthErrorMessages.message(for: .transport("offline"))) + } + + @Test("A session that came up without TLS is not something the connection form can fix") + func encryptionNotEstablished() { + let error = classify(IAMSignInError.encryptionNotEstablished) + + #expect(error.title == String(localized: "Encryption Not Established")) + #expect(!error.opensEditor) + } + + @Test("A token the server refuses is not blamed on a password the connection does not have") + func serverRefusesTheToken() { + let refused = NSError( + domain: "PostgreSQL", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "FATAL: PAM authentication failed for user \"iam_user\""] + ) + + let iam = classify(refused, signsInWithIAM: true) + let password = classify(refused, signsInWithIAM: false) + + #expect(iam.category == .auth) + #expect(iam.recovery == String( + localized: "This connection signs in with IAM, not a password. Check that the database user is set up for IAM sign-in." + )) + #expect(password.recovery != iam.recovery) + #expect(!iam.opensEditor) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SignIn/IAMPasswordMintTests.swift b/apple/TableProMobile/TableProMobileTests/SignIn/IAMPasswordMintTests.swift new file mode 100644 index 0000000000..38def5df69 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SignIn/IAMPasswordMintTests.swift @@ -0,0 +1,460 @@ +import Foundation +import os +import TableProDatabase +import TableProGoogleCloud +@testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing + +private final class RecordedAccessTokens: Sendable { + private let tokens = OSAllocatedUnfairLock<[String]>(initialState: []) + + func append(_ token: String) { + tokens.withLock { $0.append(token) } + } + + var all: [String] { + tokens.withLock { $0 } + } +} + +struct IAMPasswordMintTests { + private static let rdsHost = "mydb.abc123.us-east-1.rds.amazonaws.com" + private static let startUrl = "https://example.awsapps.com/start" + private static let now = Date(timeIntervalSince1970: 1_790_000_000) + private static let serviceAccountEmail = "reader@project.iam.gserviceaccount.com" + private static let serviceAccountJSON = """ + {"type":"service_account","client_email":"reader@project.iam.gserviceaccount.com",\ + "private_key":"-----BEGIN PRIVATE KEY-----\\nMIIB\\n-----END PRIVATE KEY-----\\n","project_id":"project"} + """ + private static let roleCredentials = AWSCredentials( + accessKeyId: "ASIAROLE", + secretAccessKey: "role-secret", + sessionToken: "role-session" + ) + + private let store = EphemeralSecureStore() + private let ssoTokens = AWSSSOTokenStore(items: EphemeralSecureStore()) + private let seenAccessTokens = RecordedAccessTokens() + + private var mint: IAMPasswordMint { + let seen = seenAccessTokens + return IAMPasswordMint( + ssoTokens: ssoTokens, + fetchRoleCredentials: { accessToken, _, _ in + seen.append(accessToken) + return Self.roleCredentials + }, + fetchGoogleToken: { key in "google-token-for-\(key.clientEmail)" }, + now: { Self.now } + ) + } + + private func makeConnection( + type: DatabaseType = .postgresql, + host: String = Self.rdsHost, + method: IAMSignIn, + sshEnabled: Bool = false, + sslEnabled: Bool = true, + fields: [String: String] = [:] + ) -> DatabaseConnection { + var additionalFields = fields + additionalFields[IAMSignIn.Field.method] = method.pickerValue + return DatabaseConnection( + type: type, + host: host, + port: 5_432, + username: "iam_user", + additionalFields: additionalFields, + sshEnabled: sshEnabled, + sshConfiguration: sshEnabled ? SSHConfiguration(host: "bastion.example.com", username: "ec2-user") : nil, + sslEnabled: sslEnabled + ) + } + + private func accessKeyConnection(host: String = Self.rdsHost, sshEnabled: Bool, fields: [String: String] = [:]) throws -> DatabaseConnection { + var accessKeyFields = fields + accessKeyFields[IAMSignIn.Field.accessKeyId] = "AKIDEXAMPLE" + let connection = makeConnection(host: host, method: .awsAccessKey, sshEnabled: sshEnabled, fields: accessKeyFields) + try store.store("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", forKey: ConnectionSecretKind.awsSecretAccessKey.account(for: connection.id)) + return connection + } + + private func signed(host: String, port: Int = 5_432, credentials: AWSCredentials) throws -> String { + try AWSIAMTokenTarget.rds( + configuredHost: host, + configuredPort: port, + preTunnelHost: nil, + preTunnelPort: nil, + defaultPort: port + ).token(username: "iam_user", fields: [:], credentials: credentials, now: Self.now) + } + + private var exampleKey: AWSCredentials { + AWSCredentials(accessKeyId: "AKIDEXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", sessionToken: nil) + } + + private func saveSSOSetup(for connection: DatabaseConnection, startUrl: String = Self.startUrl) throws { + try DeviceIAMCredentials( + ssoStartUrl: startUrl, + ssoRegion: "us-east-1", + ssoAccountId: "111122223333", + ssoRoleName: "ReadOnly" + ).save(for: connection.id, to: store) + } + + private func saveSSOToken(expiresAt: Date) throws { + try ssoTokens.save( + AWSSSOTokenCacheEntry(accessToken: "sso-access-token", expiresAt: expiresAt, region: "us-east-1", startUrl: Self.startUrl), + startUrl: Self.startUrl + ) + } + + // MARK: - AWS + + @Test("Behind SSH, the token signs the RDS Endpoint override") + func sshSignsTheOverride() async throws { + let connection = try accessKeyConnection( + host: "10.0.1.5", + sshEnabled: true, + fields: [IAMSignIn.Field.rdsEndpoint: "\(Self.rdsHost):5432"] + ) + + let token = try await mint.password(for: connection, method: .awsAccessKey, secureStore: store) + let expected = try signed(host: Self.rdsHost, credentials: exampleKey) + + #expect(token == expected) + #expect(token.hasPrefix("\(Self.rdsHost):5432/?Action=connect&DBUser=iam_user&")) + } + + @Test("Behind SSH with no override, the token signs the saved host") + func sshSignsTheSavedHost() async throws { + let connection = try accessKeyConnection(sshEnabled: true) + + let token = try await mint.password(for: connection, method: .awsAccessKey, secureStore: store) + let expected = try signed(host: Self.rdsHost, credentials: exampleKey) + + #expect(token == expected) + } + + @Test("Without SSH, an override naming another host is refused, as on the Mac") + func directConnectionRefusesAnotherOverride() async throws { + let connection = try accessKeyConnection( + host: "db.internal.example.com", + sshEnabled: false, + fields: [IAMSignIn.Field.rdsEndpoint: "\(Self.rdsHost):5432"] + ) + + await #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "db.internal.example.com")) { + try await mint.password(for: connection, method: .awsAccessKey, secureStore: store) + } + } + + @Test("An access key connection without its secret on this device says so") + func accessKeyMissing() async { + let connection = makeConnection(method: .awsAccessKey, fields: [IAMSignIn.Field.accessKeyId: "AKIDEXAMPLE"]) + + await #expect(throws: IAMSignInError.accessKeyMissing) { + try await mint.password(for: connection, method: .awsAccessKey, secureStore: store) + } + } + + @Test("A synced session token is signed into the token") + func sessionTokenIsSigned() async throws { + let connection = try accessKeyConnection(sshEnabled: false) + try store.store("session-token", forKey: ConnectionSecretKind.awsSessionToken.account(for: connection.id)) + + let token = try await mint.password(for: connection, method: .awsAccessKey, secureStore: store) + + #expect(token.contains("X-Amz-Security-Token=session-token")) + } + + @Test("A Profile connection without a key for this device names the profile") + func profileNeedsDeviceKey() async { + let connection = makeConnection(method: .awsProfile, fields: [IAMSignIn.Field.profileName: "analytics"]) + + await #expect(throws: IAMSignInError.profileNeedsDeviceKey(profile: "analytics")) { + try await mint.password(for: connection, method: .awsProfile, secureStore: store) + } + } + + @Test("A Profile connection signs with the key added for this device") + func profileSignsWithDeviceKey() async throws { + let connection = makeConnection(method: .awsProfile) + try DeviceIAMCredentials( + accessKeyId: " AKIDEXAMPLE\n", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" + ).save(for: connection.id, to: store) + + let token = try await mint.password(for: connection, method: .awsProfile, secureStore: store) + let expected = try signed(host: Self.rdsHost, credentials: exampleKey) + + #expect(token == expected) + } + + @Test("Two connections on one IAM Identity Center share one sign-in") + func ssoTokenIsShared() async throws { + let first = makeConnection(method: .awsSSO) + let second = makeConnection(type: .mysql, method: .awsSSO) + try saveSSOSetup(for: first) + try saveSSOSetup(for: second, startUrl: Self.startUrl + "/") + try saveSSOToken(expiresAt: Self.now.addingTimeInterval(3_600)) + + let firstToken = try await mint.password(for: first, method: .awsSSO, secureStore: store) + _ = try await mint.password(for: second, method: .awsSSO, secureStore: store) + let expected = try signed(host: Self.rdsHost, credentials: Self.roleCredentials) + + #expect(seenAccessTokens.all == ["sso-access-token", "sso-access-token"]) + #expect(firstToken == expected) + } + + @Test("IAM Identity Center that is not set up on this device says so") + func ssoNotSetUp() async throws { + let connection = makeConnection(method: .awsSSO) + try saveSSOToken(expiresAt: Self.now.addingTimeInterval(3_600)) + + await #expect(throws: IAMSignInError.ssoNotSetUp) { + try await mint.password(for: connection, method: .awsSSO, secureStore: store) + } + } + + @Test("An expired IAM Identity Center token asks for a sign-in") + func expiredSSOToken() async throws { + let connection = makeConnection(method: .awsSSO) + try saveSSOSetup(for: connection) + try saveSSOToken(expiresAt: Self.now.addingTimeInterval(-60)) + + await #expect(throws: IAMSignInError.ssoSignInRequired) { + try await mint.password(for: connection, method: .awsSSO, secureStore: store) + } + #expect(seenAccessTokens.all.isEmpty) + } + + @Test("A session the portal no longer accepts is cleared and asks for a sign-in") + func revokedSSOSession() async throws { + let connection = makeConnection(method: .awsSSO) + try saveSSOSetup(for: connection) + try saveSSOToken(expiresAt: Self.now.addingTimeInterval(3_600)) + let revoking = IAMPasswordMint( + ssoTokens: ssoTokens, + fetchRoleCredentials: { _, _, profile in throw AWSSSOError.sessionUnauthorized(profile: profile) }, + now: { Self.now } + ) + + await #expect(throws: IAMSignInError.ssoSignInRequired) { + try await revoking.password(for: connection, method: .awsSSO, secureStore: store) + } + #expect(ssoTokens.token(startUrl: Self.startUrl) == nil) + } + + @Test("Redis with SSL off keeps the Mac's requires-TLS error") + func elastiCacheWithSSLOff() async throws { + let connection = makeConnection( + type: .redis, + host: "my-cache.abc123.use1.cache.amazonaws.com", + method: .awsAccessKey, + sslEnabled: false, + fields: [ + IAMSignIn.Field.accessKeyId: "AKIDEXAMPLE", + IAMSignIn.Field.region: "us-east-1", + IAMSignIn.Field.replicationGroupId: "my-cache" + ] + ) + try store.store("secret", forKey: ConnectionSecretKind.awsSecretAccessKey.account(for: connection.id)) + + let error = await #expect(throws: AWSAuthError.self) { + try await mint.password(for: connection, method: .awsAccessKey, secureStore: store) + } + guard case .missingConfiguration = error else { + Issue.record("Expected the requires-TLS error, got \(String(describing: error))") + return + } + } + + @Test("Redis with SSL on signs its cache name") + func elastiCacheWithSSLOn() async throws { + let connection = makeConnection( + type: .redis, + host: "my-cache.abc123.use1.cache.amazonaws.com", + method: .awsAccessKey, + fields: [ + IAMSignIn.Field.accessKeyId: "AKIDEXAMPLE", + IAMSignIn.Field.region: "us-east-1", + IAMSignIn.Field.replicationGroupId: "my-cache" + ] + ) + try store.store("secret", forKey: ConnectionSecretKind.awsSecretAccessKey.account(for: connection.id)) + + let token = try await mint.password(for: connection, method: .awsAccessKey, secureStore: store) + + #expect(token.hasPrefix("my-cache/?Action=connect&User=iam_user&")) + } + + // MARK: - Google Cloud + + @Test("A pasted key with a leading newline or byte order mark parses", arguments: ["\n", "\u{FEFF}", "\n\u{FEFF}", "\u{FEFF}\n "]) + func paddedKeyParses(padding: String) throws { + let key = try IAMPasswordMint.serviceAccountKey(padding + Self.serviceAccountJSON, whenMissing: .serviceAccountKeyMissing) + + #expect(key.clientEmail == Self.serviceAccountEmail) + } + + @Test("A service account connection signs with the synced key") + func serviceAccountSigns() async throws { + let connection = makeConnection(method: .googleServiceAccount) + try store.store("\n" + Self.serviceAccountJSON, forKey: ConnectionSecretKind.gcpServiceAccountKey.account(for: connection.id)) + + let token = try await mint.password(for: connection, method: .googleServiceAccount, secureStore: store) + + #expect(token == "google-token-for-\(Self.serviceAccountEmail)") + } + + @Test("A key that is a file path on the Mac asks for the JSON", arguments: ["/Users/me/keys/reader.json", "~/keys/reader.json"]) + func macPathIsAMacFile(path: String) async throws { + let connection = makeConnection(method: .googleServiceAccount) + try store.store(path, forKey: ConnectionSecretKind.gcpServiceAccountKey.account(for: connection.id)) + + await #expect(throws: IAMSignInError.serviceAccountKeyIsMacFile) { + try await mint.password(for: connection, method: .googleServiceAccount, secureStore: store) + } + } + + @Test("A service account connection with no key on this device says so", arguments: [nil, "", " \n\u{FEFF}"] as [String?]) + func serviceAccountKeyMissing(value: String?) { + #expect(throws: IAMSignInError.serviceAccountKeyMissing) { + try IAMPasswordMint.serviceAccountKey(value, whenMissing: .serviceAccountKeyMissing) + } + } + + @Test("A pasted PEM keeps its own message") + func pastedPEM() { + #expect(throws: GoogleAuthError.credentialIsPEM) { + try IAMPasswordMint.serviceAccountKey("-----BEGIN PRIVATE KEY-----\nMIIB\n", whenMissing: .serviceAccountKeyMissing) + } + } + + @Test("Application Default without a key for this device says what the phone needs") + func applicationDefaultNeedsDeviceKey() async { + let connection = makeConnection(method: .googleApplicationDefault) + + await #expect(throws: IAMSignInError.applicationDefaultNeedsDeviceKey) { + try await mint.password(for: connection, method: .googleApplicationDefault, secureStore: store) + } + } + + @Test("Application Default signs with the key added for this device") + func applicationDefaultSignsWithDeviceKey() async throws { + let connection = makeConnection(method: .googleApplicationDefault) + try DeviceIAMCredentials(serviceAccountKey: Self.serviceAccountJSON).save(for: connection.id, to: store) + + let token = try await mint.password(for: connection, method: .googleApplicationDefault, secureStore: store) + + #expect(token == "google-token-for-\(Self.serviceAccountEmail)") + } + + // MARK: - Device-only settings + + @Test("Device sign-in settings round trip, and clearing them deletes the item") + func deviceCredentialsRoundTrip() throws { + let id = UUID() + let saved = DeviceIAMCredentials(serviceAccountKey: Self.serviceAccountJSON, ssoStartUrl: Self.startUrl) + + try saved.save(for: id, to: store) + #expect(try DeviceIAMCredentials.load(for: id, from: store) == saved) + + try DeviceIAMCredentials().save(for: id, to: store) + #expect(try store.retrieve(forKey: ConnectionSecretKind.deviceIAM.account(for: id)) == nil) + #expect(try DeviceIAMCredentials.load(for: id, from: store).isEmpty) + } + + // MARK: - Driver factory + + @Test("An IAM connection with SSL off is raised to Required in the driver only", arguments: [DatabaseType.mysql, .postgresql]) + func tlsRaisedForIAM(type: DatabaseType) throws { + let connection = makeConnection(type: type, method: .awsAccessKey, sslEnabled: false) + + let ssl = try IOSDriverFactory().ssl(for: connection, serverName: nil) + + #expect(ssl.mode == .require) + #expect(!connection.sslEnabled) + } + + @Test("A verifying SSL mode is kept for an IAM connection") + func verifyingModeKept() throws { + var connection = makeConnection(method: .awsAccessKey) + connection.sslConfiguration = SSLConfiguration(mode: .verifyFull) + + #expect(try IOSDriverFactory().ssl(for: connection, serverName: nil).mode == .verifyFull) + } + + @Test("A password connection with SSL off stays off") + func passwordConnectionKeepsSSLOff() throws { + let connection = DatabaseConnection(type: .postgresql, host: Self.rdsHost, sslEnabled: false) + + #expect(try IOSDriverFactory().ssl(for: connection, serverName: nil).mode == .disable) + } + + @Test("Behind a tunnel, the certificate is checked against the saved host") + func serverNameIsThePreTunnelHost() throws { + let connection = DatabaseConnection(type: .postgresql, host: "127.0.0.1", sslEnabled: true) + + let tunneled = try IOSDriverFactory().ssl(for: connection, serverName: Self.rdsHost) + let direct = try IOSDriverFactory().ssl(for: connection, serverName: nil) + + #expect(tunneled.serverName == Self.rdsHost) + #expect(direct.serverName == nil) + } + + @Test("MySQL signs in with a token only for an IAM connection") + func mySQLSignsInWithToken() throws { + let iam = makeConnection(type: .mysql, method: .awsAccessKey, sslEnabled: false) + let plain = DatabaseConnection(type: .mysql, host: Self.rdsHost) + let factory = IOSDriverFactory() + + let iamDriver = try #require(try factory.createDriver(for: iam, password: "token", preTunnelHost: nil) as? MySQLDriver) + let passwordDriver = try #require(try factory.createDriver(for: plain, password: "pw") as? MySQLDriver) + + #expect(iamDriver.signsInWithToken) + #expect(iamDriver.ssl.mode == .require) + #expect(!passwordDriver.signsInWithToken) + } + + @Test("Redis checks its certificate against the saved host behind a tunnel") + func redisGetsServerName() throws { + let connection = DatabaseConnection(type: .redis, host: "127.0.0.1", port: 6_379, sslEnabled: true) + + let driver = try IOSDriverFactory().createDriver(for: connection, password: nil, preTunnelHost: "cache.example.com") + let redis = try #require(driver as? RedisDriver) + + #expect(redis.ssl.serverName == "cache.example.com") + } + + @Test("A password connection keeps its stored password") + func passwordConnectionUsesStoredPassword() async throws { + let connection = DatabaseConnection(type: .postgresql, host: Self.rdsHost) + + let password = try await IOSDriverFactory(mint: mint).resolvePassword(for: connection, stored: "pw", secureStore: store) + + #expect(password == "pw") + } + + @Test("An engine without IAM keeps its stored password despite a synced awsAuth") + func engineWithoutIAMUsesStoredPassword() async throws { + let connection = DatabaseConnection(type: .redshift, host: Self.rdsHost, additionalFields: [IAMSignIn.Field.method: "accessKey"]) + + let password = try await IOSDriverFactory(mint: mint).resolvePassword(for: connection, stored: "pw", secureStore: store) + + #expect(password == "pw") + } + + @Test("An IAM connection signs a token instead of the stored password") + func iamConnectionSignsAToken() async throws { + let connection = try accessKeyConnection(sshEnabled: false) + + let password = try await IOSDriverFactory(mint: mint).resolvePassword(for: connection, stored: "stale", secureStore: store) + let expected = try signed(host: Self.rdsHost, credentials: exampleKey) + + #expect(password == expected) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SignIn/IAMSignInTests.swift b/apple/TableProMobile/TableProMobileTests/SignIn/IAMSignInTests.swift new file mode 100644 index 0000000000..f637b4dcd3 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SignIn/IAMSignInTests.swift @@ -0,0 +1,123 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing + +struct IAMSignInTests { + @Test("MySQL and PostgreSQL offer AWS and Google Cloud IAM, signing RDS tokens", arguments: [DatabaseType.mysql, .postgresql]) + func rdsWithGoogleCloud(type: DatabaseType) throws { + let engine = try #require(IAMSignIn.engine(for: type)) + + #expect(engine.methods == [.awsAccessKey, .awsProfile, .awsSSO, .googleApplicationDefault, .googleServiceAccount]) + #expect(engine.awsService == .rds) + } + + @Test("MariaDB offers AWS IAM only") + func mariaDBIsAWSOnly() throws { + let engine = try #require(IAMSignIn.engine(for: .mariadb)) + + #expect(engine.methods == [.awsAccessKey, .awsProfile, .awsSSO]) + #expect(engine.awsService == .rds) + } + + @Test("Redis offers AWS IAM and signs for its ElastiCache cache name") + func redisSignsElastiCache() throws { + let engine = try #require(IAMSignIn.engine(for: .redis)) + + #expect(engine.methods == [.awsAccessKey, .awsProfile, .awsSSO]) + #expect(engine.awsService == .elastiCache) + } + + @Test( + "Every other engine offers no IAM sign-in", + arguments: [DatabaseType.tidb, .oceanbase, .redshift, .mssql, .oracle, .sqlite, .duckdb] + ) + func otherEnginesOfferNone(type: DatabaseType) { + #expect(IAMSignIn.engine(for: type) == nil) + #expect(IAMSignIn.methods(for: type).isEmpty) + } + + @Test("The picker value parses as the Mac's does", arguments: [ + (nil, nil), + ("", nil), + ("off", nil), + ("accessKey", IAMSignIn.awsAccessKey), + ("profile", .awsProfile), + ("sso", .awsSSO), + ("gcpApplicationDefault", .googleApplicationDefault), + ("gcpServiceAccount", .googleServiceAccount), + ("somethingNewer", .awsAccessKey) + ] as [(String?, IAMSignIn?)]) + func parsesPickerValue(value: String?, expected: IAMSignIn?) { + #expect(IAMSignIn(pickerValue: value) == expected) + } + + @Test("Each method writes back the picker value it was read from", arguments: IAMSignIn.allCases) + func pickerValueRoundTrips(method: IAMSignIn) { + #expect(IAMSignIn(pickerValue: method.pickerValue) == method) + } + + @Test("The picker values are the Mac's Authentication options") + func pickerValuesMatchPluginKit() throws { + let picker = try #require(GoogleCloudSQLAuthFields.standardWithAWS().first { $0.id == IAMSignIn.Field.method }) + guard case .dropdown(let options) = picker.fieldType else { + Issue.record("The Authentication field is not a picker") + return + } + let macValues = options.map(\.value) + let iOSValues = [IAMSignIn.passwordPickerValue] + IAMSignIn.allCases.map(\.pickerValue) + + #expect(macValues == iOSValues) + } + + @Test("The field ids are the ones the Mac declares") + func fieldIdsMatchPluginKit() { + let ids = Set(GoogleCloudSQLAuthFields.standardWithAWS().map(\.id)) + + #expect(ids.isSuperset(of: [ + IAMSignIn.Field.method, + IAMSignIn.Field.region, + IAMSignIn.Field.accessKeyId, + IAMSignIn.Field.profileName + ])) + #expect(AWSAuthFields.rdsEndpointField().id == IAMSignIn.Field.rdsEndpoint) + #expect(AWSAuthFields.elastiCacheReplicationGroupField().id == IAMSignIn.Field.replicationGroupId) + } + + @Test("The Mac's secure fields are read under the names the Mac stores them by") + func secureFieldsMatchSecretKinds() { + let macAccounts = GoogleCloudSQLAuthFields.standardWithAWS().filter { $0.isSecure }.map { "com.TablePro.plugin.\($0.id)." } + let kinds: [ConnectionSecretKind] = [.awsSecretAccessKey, .awsSessionToken, .gcpServiceAccountKey] + let iOSAccounts = kinds.map(\.prefix) + + #expect(macAccounts == iOSAccounts) + } + + @Test("A connection signs in with the method its engine offers") + func connectionReadsItsMethod() { + let connection = DatabaseConnection(type: .postgresql, additionalFields: ["awsAuth": "gcpServiceAccount"]) + + #expect(connection.iamSignIn == .googleServiceAccount) + } + + @Test("An engine without IAM ignores a synced awsAuth", arguments: [DatabaseType.redshift, .tidb, .oceanbase]) + func engineWithoutIAMIgnoresIt(type: DatabaseType) { + let connection = DatabaseConnection(type: type, additionalFields: ["awsAuth": "accessKey"]) + + #expect(connection.iamSignIn == nil) + } + + @Test("A method the engine does not offer is ignored") + func unofferedMethodIsIgnored() { + let connection = DatabaseConnection(type: .mariadb, additionalFields: ["awsAuth": "gcpServiceAccount"]) + + #expect(connection.iamSignIn == nil) + } + + @Test("A password connection has no IAM sign-in") + func passwordConnection() { + #expect(DatabaseConnection(type: .mysql, additionalFields: ["awsAuth": "off"]).iamSignIn == nil) + #expect(DatabaseConnection(type: .mysql).iamSignIn == nil) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SignIn/SignInOfferTests.swift b/apple/TableProMobile/TableProMobileTests/SignIn/SignInOfferTests.swift new file mode 100644 index 0000000000..17cc7d3ddf --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SignIn/SignInOfferTests.swift @@ -0,0 +1,208 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing + +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct SignInOfferTests { + private let startUrl = "https://example.awsapps.com/start" + + @MainActor + private final class Runs { + var count = 0 + } + + private func ssoConnection(in store: EphemeralSecureStore) throws -> DatabaseConnection { + let connection = DatabaseConnection( + type: .postgresql, + host: "db.abc123.us-east-1.rds.amazonaws.com", + port: 5_432, + username: "iam_user", + additionalFields: [IAMSignIn.Field.method: IAMSignIn.awsSSO.pickerValue] + ) + try DeviceIAMCredentials( + ssoStartUrl: startUrl, + ssoRegion: "us-east-1", + ssoAccountId: "111122223333", + ssoRoleName: "ReadOnly" + ).save(for: connection.id, to: store) + return connection + } + + private func method(_ signIn: @escaping @MainActor (ConnectionPromptQueue, Int) async throws -> Void) -> SignInMethod { + SignInMethod( + question: ConnectionPrompt(title: "Sign-In Required", message: "Sign in?", confirmTitle: "Sign In"), + signIn: signIn + ) + } + + private func answer(_ queue: ConnectionPromptQueue, accepted: Bool) async { + await ObservedCondition.wait { queue.current != nil } + guard let prompt = queue.current else { return } + queue.answer(prompt.id, accepted: accepted) + } + + private func entry() -> AWSSSOTokenCacheEntry { + AWSSSOTokenCacheEntry( + accessToken: "portal-token", + expiresAt: Date().addingTimeInterval(3_600), + region: "us-east-1", + startUrl: startUrl + ) + } + + @Test("An expired Entra sign-in is offered through Entra") + func entraSignInIsOffered() { + let offered = SignInOffer.method( + for: EntraOAuthError.signInRequired, + connection: DatabaseConnection(type: .mssql), + secureStore: EphemeralSecureStore() + ) + + #expect(offered?.question.title == String(localized: "Microsoft Entra ID Sign-In Required")) + } + + @Test("An IAM Identity Center connection whose sign-in expired is offered the AWS sign-in") + func identityCenterSignInIsOffered() throws { + let store = EphemeralSecureStore() + let connection = try ssoConnection(in: store) + + let offered = SignInOffer.method(for: IAMSignInError.ssoSignInRequired, connection: connection, secureStore: store) + + #expect(offered?.question.title == String(localized: "AWS SSO Sign-In Required")) + #expect(AWSSSOSignIn.setup(for: connection.id, secureStore: store) == AWSSSOSignIn.Setup(startUrl: startUrl, region: "us-east-1")) + } + + @Test("Nothing is offered when IAM Identity Center is not set up on this device") + func noOfferWithoutSetup() { + let connection = DatabaseConnection( + type: .postgresql, + additionalFields: [IAMSignIn.Field.method: IAMSignIn.awsSSO.pickerValue] + ) + + let offered = SignInOffer.method(for: IAMSignInError.ssoSignInRequired, connection: connection, secureStore: EphemeralSecureStore()) + + #expect(offered == nil) + } + + @Test("A failure a sign-in cannot fix is shown, not offered", arguments: [ + EntraOAuthError.accessDenied as Error, + IAMSignInError.accessKeyMissing, + IAMSignInError.ssoNotSetUp, + AWSSSOLoginError.authorizationTimedOut, + ConnectionError.notConnected, + ]) + func noOfferForOtherFailures(error: Error) throws { + let store = EphemeralSecureStore() + let connection = try ssoConnection(in: store) + + #expect(SignInOffer.method(for: error, connection: connection, secureStore: store) == nil) + } + + @Test("Declining the question runs no sign-in") + func declineRunsNothing() async { + let queue = ConnectionPromptQueue() + let runs = Runs() + let offered = method { _, _ in runs.count += 1 } + + async let outcome = SignInOffer.offer(offered, prompts: queue) + await answer(queue, accepted: false) + + guard case .declined = await outcome else { + Issue.record("A declined question should report declined") + return + } + #expect(runs.count == 0) + } + + @Test("A sign-in that finishes reports signed in, so the connect is retried") + func finishedSignIn() async { + let queue = ConnectionPromptQueue() + let runs = Runs() + let offered = method { _, _ in runs.count += 1 } + + async let outcome = SignInOffer.offer(offered, prompts: queue) + await answer(queue, accepted: true) + + guard case .signedIn = await outcome else { + Issue.record("A finished sign-in should report signed in") + return + } + #expect(runs.count == 1) + } + + @Test("A sign-in that fails reports its own error, not the connect error that asked for it") + func failedSignInKeepsItsError() async { + let queue = ConnectionPromptQueue() + let offered = method { _, _ in throw AWSSSOLoginError.accessDenied } + + async let outcome = SignInOffer.offer(offered, prompts: queue) + await answer(queue, accepted: true) + + guard case .failed(let error) = await outcome else { + Issue.record("A failed sign-in should report its error") + return + } + #expect(error as? AWSSSOLoginError == .accessDenied) + } + + @Test("The AWS sign-in saves its token under the start URL, for every connection on that Identity Center") + func identityCenterTokenIsSaved() async throws { + let tokens = AWSSSOTokenStore(items: EphemeralSecureStore()) + let signedIn = entry() + + try await AWSSSOSignIn.signIn( + AWSSSOSignIn.Setup(startUrl: startUrl, region: "us-east-1"), + tokens: tokens, + authorize: { _, _ in signedIn }, + presentCode: { _, _ in } + ) + + #expect(tokens.token(startUrl: startUrl)?.accessToken == signedIn.accessToken) + #expect(tokens.token(startUrl: startUrl + "/")?.accessToken == signedIn.accessToken) + } + + @Test("A failed AWS sign-in saves nothing and passes its error on") + func failedIdentityCenterSignInSavesNothing() async { + let tokens = AWSSSOTokenStore(items: EphemeralSecureStore()) + + await #expect(throws: AWSSSOLoginError.accessDenied) { + try await AWSSSOSignIn.signIn( + AWSSSOSignIn.Setup(startUrl: startUrl, region: "us-east-1"), + tokens: tokens, + authorize: { _, _ in throw AWSSSOLoginError.accessDenied }, + presentCode: { _, _ in } + ) + } + #expect(tokens.token(startUrl: startUrl) == nil) + } + + @Test("Every portal error the screen calls an expired sign-in is one the connect offers to fix", arguments: [ + AWSSSOError.configReadFailed, + .profileNotFound("p"), + .profileMissingFields(profile: "p"), + .sessionNotFound(profile: "p", session: "s"), + .sessionMissingFields(session: "s"), + .profileMissingUrlOrRegion("p"), + .tokenCacheNotFound(profile: "p"), + .tokenCacheMalformed(profile: "p"), + .tokenExpired(profile: "p"), + .urlBuildFailed(profile: "p"), + .networkFailure(profile: "p", underlying: "offline"), + .invalidResponse(profile: "p"), + .sessionUnauthorized(profile: "p"), + .roleNotAccessible(role: "r", account: "a"), + .portalError(profile: "p", status: 500), + .responseDecodeFailed(profile: "p"), + .credentialsAlreadyExpired(profile: "p"), + ]) + func expiredSignInsAreOffered(error: AWSSSOError) { + let shown = ErrorClassifier.classify(error, context: ErrorContext(operation: "connect")) + let saysSignInExpired = shown.title == String(localized: "AWS SSO Sign-In Required") + + #expect(AWSSSOSignIn.needsSignIn(error) == saysSignInExpired) + } +} diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index ab4d328e57..dca895f599 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -119,6 +119,7 @@ targets: - TableProCoreTypes - TableProDatabase - TableProGeometry + - TableProGoogleCloud - TableProImport - TableProModels - TableProMSSQLCore diff --git a/apple/TableProTests/AWS/AWSIAMAuthTests.swift b/apple/TableProTests/AWS/AWSIAMAuthTests.swift index e048cb2b3d..c99bff6b9a 100644 --- a/apple/TableProTests/AWS/AWSIAMAuthTests.swift +++ b/apple/TableProTests/AWS/AWSIAMAuthTests.swift @@ -1,14 +1,3 @@ -// -// AWSIAMAuthTests.swift -// TableProTests -// -// Covers the pure, deterministic parts of RDS IAM authentication: the SigV4 -// primitives against published test vectors, the presigned token structure, -// region derivation from RDS hostnames, the access-key credential resolver, -// and the AWS config INI parser. Profile/SSO resolution and the live SSO -// network exchange require the filesystem/AWS and are not unit-tested. -// - import Foundation import TableProPluginKit import Testing @@ -37,220 +26,6 @@ struct AWSSigV4Tests { } } -struct RDSAuthTokenGeneratorTests { - 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 { - RDSAuthTokenGenerator.generateToken( - host: "mydb.us-east-1.rds.amazonaws.com", - port: 5_432, - region: "us-east-1", - username: "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("mydb.us-east-1.rds.amazonaws.com:5432/?")) - #expect(token.contains("Action=connect")) - #expect(token.contains("DBUser=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("%2Frds-db%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")) - } -} - -struct RDSEndpointTests { - @Test("Derives region from cluster hostname") - func clusterHostname() { - #expect(RDSEndpoint.region(forHost: "mydb.cluster-abc123.us-east-1.rds.amazonaws.com") == "us-east-1") - } - - @Test("Derives region from instance hostname") - func instanceHostname() { - #expect(RDSEndpoint.region(forHost: "mydb.abc123.us-west-2.rds.amazonaws.com") == "us-west-2") - } - - @Test("Derives region from China partition hostname") - func chinaHostname() { - #expect(RDSEndpoint.region(forHost: "mydb.abc.cn-north-1.rds.amazonaws.com.cn") == "cn-north-1") - } - - @Test("Returns nil for non-RDS hosts") - func nonRDSHost() { - #expect(RDSEndpoint.region(forHost: "localhost") == nil) - #expect(RDSEndpoint.region(forHost: "db.example.com") == nil) - } -} - -struct RDSSigningEndpointResolverTests { - private func resolve( - host: String = "mydb.abc123.us-east-1.rds.amazonaws.com", - port: Int = 5_432, - preTunnelHost: String? = nil, - preTunnelPort: Int? = nil, - override: String? = nil, - defaultPort: Int = 5_432 - ) throws -> RDSSigningEndpoint { - try RDSSigningEndpointResolver.resolve( - configuredHost: host, - configuredPort: port, - preTunnelHost: preTunnelHost, - preTunnelPort: preTunnelPort, - override: override, - defaultPort: defaultPort - ) - } - - @Test("A direct connection signs its own endpoint") - func directConnection() throws { - let endpoint = try resolve() - #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.us-east-1.rds.amazonaws.com", port: 5_432)) - } - - @Test("A tunneled connection signs the endpoint from before the rewrite") - func tunneledConnection() throws { - let endpoint = try resolve( - host: "127.0.0.1", - port: 62_000, - preTunnelHost: "mydb.abc123.us-east-1.rds.amazonaws.com", - preTunnelPort: 5_432 - ) - #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.us-east-1.rds.amazonaws.com", port: 5_432)) - } - - @Test("The endpoint override wins over the tunnel and the configured host") - func overrideWins() throws { - let endpoint = try resolve( - host: "127.0.0.1", - port: 62_000, - preTunnelHost: "old.abc123.us-east-1.rds.amazonaws.com", - preTunnelPort: 5_432, - override: "mydb.abc123.ap-south-1.rds.amazonaws.com:5433" - ) - #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.ap-south-1.rds.amazonaws.com", port: 5_433)) - } - - @Test("An override without a port falls back to the engine default") - func overrideWithoutPort() throws { - let endpoint = try resolve( - host: "127.0.0.1", - port: 54_321, - override: "mydb.abc123.ap-south-1.rds.amazonaws.com", - defaultPort: 5_432 - ) - #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.ap-south-1.rds.amazonaws.com", port: 5_432)) - } - - @Test("A blank override is ignored") - func blankOverrideIgnored() throws { - let endpoint = try resolve(override: " ") - #expect(endpoint.host == "mydb.abc123.us-east-1.rds.amazonaws.com") - } - - @Test("An override keeps only the host and port of a pasted URL") - func overrideStripsSchemeAndPath() throws { - let endpoint = try resolve(override: "postgresql://mydb.abc123.us-east-1.rds.amazonaws.com:5432/postgres") - #expect(endpoint == RDSSigningEndpoint(host: "mydb.abc123.us-east-1.rds.amazonaws.com", port: 5_432)) - } - - @Test("A local forward with no override is rejected before the token is signed") - func loopbackIsRejected() { - for host in ["127.0.0.1", "localhost", "::1", "127.0.0.53"] { - #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: host)) { - _ = try resolve(host: host, port: 54_321) - } - } - } - - @Test("A tunnel to a loopback destination is rejected too") - func loopbackPreTunnelHostIsRejected() { - #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "localhost")) { - _ = try resolve(host: "127.0.0.1", port: 62_000, preTunnelHost: "localhost", preTunnelPort: 5_432) - } - } - - @Test("A malformed override is rejected with the value the user typed") - func malformedOverrideIsRejected() { - for value in ["mydb.rds.amazonaws.com:port", "mydb.rds.amazonaws.com:0", ":5432", "127.0.0.1:5432"] { - #expect(throws: AWSAuthError.rdsEndpointInvalid(value)) { - _ = try resolve(override: value) - } - } - } - - @Test("An override may not redirect the token away from the host being dialled") - func overrideCannotRedirectToAnotherHost() { - #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "evil.example.com")) { - _ = try resolve( - host: "evil.example.com", - port: 5_432, - override: "prod.abc123.us-east-1.rds.amazonaws.com:5432" - ) - } - } - - @Test("An override that restates the dialled host is accepted") - func overrideMatchingDialledHostIsAccepted() throws { - let endpoint = try resolve( - host: "mydb.abc123.us-east-1.rds.amazonaws.com", - port: 5_432, - override: "MyDB.abc123.us-east-1.RDS.amazonaws.com:5433" - ) - #expect(endpoint.port == 5_433) - } - - @Test("A pre-tunnel host is ignored when the socket is not a local forward") - func preTunnelHostIgnoredForRemoteDial() throws { - let endpoint = try resolve( - host: "evil.example.com", - port: 5_432, - preTunnelHost: "prod.abc123.us-east-1.rds.amazonaws.com", - preTunnelPort: 5_432 - ) - #expect(endpoint == RDSSigningEndpoint(host: "evil.example.com", port: 5_432)) - } - - @Test("The region comes from the signing host, not the local forward") - func regionFollowsSigningHost() throws { - let endpoint = try resolve( - host: "127.0.0.1", - port: 62_000, - preTunnelHost: "mydb.abc123.ap-south-1.rds.amazonaws.com", - preTunnelPort: 5_432 - ) - #expect(RDSEndpoint.region(forHost: endpoint.host) == "ap-south-1") - } -} - struct AWSCredentialResolverTests { @Test("Resolves static access-key credentials") func staticCredentials() async throws { diff --git a/apple/TableProTests/AWS/AWSIAMTokenSignerTests.swift b/apple/TableProTests/AWS/AWSIAMTokenSignerTests.swift new file mode 100644 index 0000000000..f8238b3a3e --- /dev/null +++ b/apple/TableProTests/AWS/AWSIAMTokenSignerTests.swift @@ -0,0 +1,90 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct AWSIAMTokenSignerTests { + private static let accessKeyFields = [ + "awsAuth": "accessKey", + "awsAccessKeyId": "AKIDEXAMPLE", + "awsSecretAccessKey": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" + ] + private static let rdsHost = "mydb.abc123.us-east-1.rds.amazonaws.com" + private static let cacheHost = "my-cache.abc123.use1.cache.amazonaws.com" + + private func makeSigner( + for connection: DatabaseConnection, + fields: [String: String] = AWSIAMTokenSignerTests.accessKeyFields + ) -> AWSIAMTokenSigner { + AWSIAMTokenSigner(connection: connection, source: "accessKey", username: "iam_user", fields: fields) + } + + private func tunneledPostgreSQL() -> DatabaseConnection { + DatabaseConnection( + name: "rds", + host: "127.0.0.1", + port: 62_000, + username: "iam_user", + type: .postgresql, + additionalFields: [ + DatabaseConnection.preTunnelHostKey: Self.rdsHost, + DatabaseConnection.preTunnelPortKey: "5432" + ] + ) + } + + private func redis(sslMode: SSLMode) -> DatabaseConnection { + DatabaseConnection( + name: "cache", + host: Self.cacheHost, + port: 6_379, + username: "iam_user", + type: .redis, + sslConfig: SSLConfiguration(mode: sslMode) + ) + } + + private var cacheFields: [String: String] { + Self.accessKeyFields.merging(["awsRegion": "us-east-1", "awsReplicationGroupId": "my-cache"]) { _, new in new } + } + + @Test("A tunneled PostgreSQL connection signs the endpoint from before the tunnel") + func tunneledPostgreSQLSignsPreTunnelHost() async throws { + let signer = makeSigner(for: tunneledPostgreSQL()) + #expect(signer.target == .rds( + configuredHost: "127.0.0.1", + configuredPort: 62_000, + preTunnelHost: Self.rdsHost, + preTunnelPort: 5_432, + defaultPort: 5_432 + )) + + let token = try await signer.token(deadline: nil) + #expect(token.hasPrefix("\(Self.rdsHost):5432/?Action=connect&DBUser=iam_user&")) + #expect(token.contains("X-Amz-Credential=AKIDEXAMPLE%2F")) + #expect(token.contains("%2Fus-east-1%2Frds-db%2Faws4_request")) + } + + @Test("Redis with SSL off throws the requires-TLS error") + func redisWithSSLOffThrowsTLSError() async { + let signer = makeSigner(for: redis(sslMode: .disabled), fields: cacheFields) + #expect(signer.target == .elastiCache(host: Self.cacheHost, tlsDisabled: true)) + + let requiresTLS = AWSAuthError.missingConfiguration( + String(localized: "ElastiCache IAM authentication requires TLS. Enable SSL in the connection's SSL settings.") + ) + await #expect(throws: requiresTLS) { + _ = try await signer.token(deadline: nil) + } + } + + @Test("Redis with SSL on signs the cache name") + func redisWithSSLOnSigns() async throws { + let signer = makeSigner(for: redis(sslMode: .required), fields: cacheFields) + #expect(signer.target == .elastiCache(host: Self.cacheHost, tlsDisabled: false)) + + let token = try await signer.token(deadline: nil) + #expect(token.hasPrefix("my-cache/?Action=connect&User=iam_user&")) + } +} diff --git a/apple/changelog.d/3689.added.md b/apple/changelog.d/3689.added.md new file mode 100644 index 0000000000..69719ef5fe --- /dev/null +++ b/apple/changelog.d/3689.added.md @@ -0,0 +1 @@ +AWS IAM and Google Cloud IAM sign-in for MySQL, MariaDB, PostgreSQL and Redis connections on iPhone and iPad. diff --git a/apple/changelog.d/3689.fixed.1.md b/apple/changelog.d/3689.fixed.1.md new file mode 100644 index 0000000000..b6ea439a41 --- /dev/null +++ b/apple/changelog.d/3689.fixed.1.md @@ -0,0 +1 @@ +Verify Identity through an SSH tunnel on iPhone and iPad failing for PostgreSQL and Redis. diff --git a/apple/changelog.d/3689.fixed.2.md b/apple/changelog.d/3689.fixed.2.md new file mode 100644 index 0000000000..699e6aea59 --- /dev/null +++ b/apple/changelog.d/3689.fixed.2.md @@ -0,0 +1 @@ +Microsoft Entra ID sign-in failures on iPhone and iPad not saying why the sign-in failed. diff --git a/apple/changelog.d/3689.fixed.md b/apple/changelog.d/3689.fixed.md new file mode 100644 index 0000000000..0d71d6c4b6 --- /dev/null +++ b/apple/changelog.d/3689.fixed.md @@ -0,0 +1 @@ +AWS IAM sign-in failing to find the region when the RDS host was typed in capitals.