diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0294f6a..f8a28c7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- SQL Server connections can now use Windows Authentication (Kerberos) on macOS. Pick Windows Authentication in the connection form to sign in with the Kerberos ticket you already have from `kinit`, or enter a Kerberos principal and password to sign in with your domain credentials. Connect by hostname, not IP address. (#1879) - Teradata support through a downloadable driver written in native Swift. Connect over TD2 or TDNEGO logon, optionally with TLS, browse databases, tables, and columns, run SQL, edit rows, and create or alter tables. (#1867) ### Fixed diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLAuthMethod.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLAuthMethod.swift new file mode 100644 index 000000000..129e75d9c --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLAuthMethod.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum MSSQLAuthMethod: String, Sendable, Equatable { + case sqlServer = "sql" + case windows +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift index 9af76fb37..2a0392487 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -10,6 +10,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public var encryptionFlag: String public var applicationName: String public var loginTimeoutSeconds: Int + public var authMethod: MSSQLAuthMethod public static let defaultPort = 1433 public static let defaultSchema = "dbo" @@ -26,12 +27,20 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { schema: String = MSSQLConnectionOptions.defaultSchema, encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag, applicationName: String = MSSQLConnectionOptions.defaultApplicationName, - loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds + loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds, + authMethod: MSSQLAuthMethod = .sqlServer ) { self.host = host self.port = port - self.user = user - self.password = password + self.authMethod = authMethod + switch authMethod { + case .sqlServer: + self.user = user + self.password = password + case .windows: + self.user = "" + self.password = "" + } self.database = database self.schema = schema self.encryptionFlag = encryptionFlag @@ -43,10 +52,15 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public extension MSSQLConnectionOptions { enum AdditionalFieldKey { public static let schema = "mssqlSchema" + public static let authMethod = "mssqlAuthMethod" } static func schema(from additionalFields: [String: String]) -> String { let raw = additionalFields[AdditionalFieldKey.schema] ?? "" return raw.isEmpty ? defaultSchema : raw } + + static func authMethod(from additionalFields: [String: String]) -> MSSQLAuthMethod { + MSSQLAuthMethod(rawValue: additionalFields[AdditionalFieldKey.authMethod] ?? "") ?? .sqlServer + } } diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift index a8970608a..38c59a6d6 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift @@ -9,12 +9,23 @@ public enum MSSQLTLSFailureKind: Sendable { case cipherMismatch } +public enum MSSQLKerberosFailureKind: Sendable, Equatable { + case noCredential + case principalUnknown + case wrongPassword + case spnNotFound + case clockSkew + case realmNotResolved + case ticketExpired +} + public enum MSSQLCoreError: LocalizedError, Sendable { case connectionFailed(String) case notConnected case queryFailed(String) case cancelled case tlsHandshakeFailed(kind: MSSQLTLSFailureKind, serverMessage: String) + case kerberosAuthFailed(kind: MSSQLKerberosFailureKind, serverMessage: String) public var errorDescription: String? { switch self { @@ -28,6 +39,8 @@ public enum MSSQLCoreError: LocalizedError, Sendable { return String(localized: "Query was cancelled") case .tlsHandshakeFailed(_, let serverMessage): return String(format: String(localized: "TLS handshake failed: %@"), serverMessage) + case .kerberosAuthFailed(_, let serverMessage): + return String(format: String(localized: "Kerberos authentication failed: %@"), serverMessage) } } } diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosClassifier.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosClassifier.swift new file mode 100644 index 000000000..c179f6634 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosClassifier.swift @@ -0,0 +1,34 @@ +import Foundation + +public enum MSSQLKerberosClassifier { + public static func classify(_ message: String) -> MSSQLKerberosFailureKind? { + let lower = message.lowercased() + if lower.contains("clock skew") { + return .clockSkew + } + if lower.contains("ticket expired") || lower.contains("credentials have expired") { + return .ticketExpired + } + if lower.contains("no credentials cache") || lower.contains("no valid credentials") + || lower.contains("no credential") { + return .noCredential + } + if lower.contains("preauthentication failed") || lower.contains("password incorrect") + || lower.contains("integrity check failed") { + return .wrongPassword + } + if lower.contains("client not found in kerberos database") + || lower.contains("client's entry in database has expired") { + return .principalUnknown + } + if lower.contains("server not found in kerberos database") { + return .spnNotFound + } + if lower.contains("cannot find kdc") || lower.contains("cannot resolve network address") + || lower.contains("unable to reach any kdc") || lower.contains("cannot determine realm") + || lower.contains("cannot locate default realm") { + return .realmNotResolved + } + return nil + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLConnectionOptionsAuthMethodTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLConnectionOptionsAuthMethodTests.swift new file mode 100644 index 000000000..f8d897076 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLConnectionOptionsAuthMethodTests.swift @@ -0,0 +1,47 @@ +import Testing +@testable import TableProMSSQLCore + +@Suite("MSSQL auth method") +struct MSSQLConnectionOptionsAuthMethodTests { + @Test("SQL Server auth passes username and password through") + func sqlServerKeepsCredentials() { + let options = MSSQLConnectionOptions( + host: "db.example.com", + user: "sa", + password: "hunter2", + database: "app", + authMethod: .sqlServer + ) + #expect(options.user == "sa") + #expect(options.password == "hunter2") + #expect(options.authMethod == .sqlServer) + } + + @Test("Windows auth blanks username and password so FreeTDS takes the GSS path") + func windowsBlanksCredentials() { + let options = MSSQLConnectionOptions( + host: "db.example.com", + user: "sa", + password: "hunter2", + database: "app", + authMethod: .windows + ) + #expect(options.user == "") + #expect(options.password == "") + #expect(options.authMethod == .windows) + } + + @Test("Default auth method is SQL Server") + func defaultsToSqlServer() { + let options = MSSQLConnectionOptions(host: "h", user: "u", password: "p", database: "d") + #expect(options.authMethod == .sqlServer) + } + + @Test("authMethod(from:) resolves the additional field, defaulting to SQL Server") + func resolvesFromAdditionalFields() { + #expect(MSSQLConnectionOptions.authMethod(from: ["mssqlAuthMethod": "windows"]) == .windows) + #expect(MSSQLConnectionOptions.authMethod(from: ["mssqlAuthMethod": "sql"]) == .sqlServer) + #expect(MSSQLConnectionOptions.authMethod(from: [:]) == .sqlServer) + #expect(MSSQLConnectionOptions.authMethod(from: ["mssqlAuthMethod": "nonsense"]) == .sqlServer) + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosClassifierTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosClassifierTests.swift new file mode 100644 index 000000000..67242a8c0 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosClassifierTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import TableProMSSQLCore + +@Suite("MSSQL Kerberos Classifier") +struct MSSQLKerberosClassifierTests { + @Test("No credentials cache → noCredential") + func noCredential() { + #expect(MSSQLKerberosClassifier.classify("gss_init_sec_context: No credentials cache found") == .noCredential) + } + + @Test("Preauthentication failed → wrongPassword") + func wrongPassword() { + #expect(MSSQLKerberosClassifier.classify("krb5: Preauthentication failed") == .wrongPassword) + } + + @Test("Client not found in Kerberos database → principalUnknown") + func principalUnknown() { + #expect(MSSQLKerberosClassifier.classify("Client not found in Kerberos database") == .principalUnknown) + } + + @Test("Server not found in Kerberos database → spnNotFound") + func spnNotFound() { + #expect(MSSQLKerberosClassifier.classify("Server not found in Kerberos database") == .spnNotFound) + } + + @Test("Clock skew → clockSkew") + func clockSkew() { + #expect(MSSQLKerberosClassifier.classify("Clock skew too great") == .clockSkew) + } + + @Test("Cannot find KDC → realmNotResolved") + func realmNotResolved() { + #expect(MSSQLKerberosClassifier.classify("Cannot find KDC for realm CONTOSO.COM") == .realmNotResolved) + } + + @Test("Ticket expired → ticketExpired") + func ticketExpired() { + #expect(MSSQLKerberosClassifier.classify("Ticket expired") == .ticketExpired) + } + + @Test("An unrelated error is not classified as Kerberos") + func unrelatedReturnsNil() { + #expect(MSSQLKerberosClassifier.classify("certificate verify failed") == nil) + #expect(MSSQLKerberosClassifier.classify("Login failed for user 'sa'") == nil) + } +} diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index 4f6354b10..e1639f81e 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -172,6 +172,9 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { if let kind = MSSQLTLSClassifier.classifySSLError(detail) { throw MSSQLCoreError.tlsHandshakeFailed(kind: kind, serverMessage: detail) } + if options.authMethod == .windows, let kind = MSSQLKerberosClassifier.classify(detail) { + throw MSSQLCoreError.kerberosAuthFailed(kind: kind, serverMessage: detail) + } throw MSSQLCoreError.connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)") } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLKerberosConnectGate.swift b/Plugins/MSSQLDriverPlugin/MSSQLKerberosConnectGate.swift new file mode 100644 index 000000000..b9216d377 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLKerberosConnectGate.swift @@ -0,0 +1,80 @@ +import Darwin +import Foundation + +final class AsyncLock: @unchecked Sendable { + private let stateLock = NSLock() + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if tryAcquireImmediately() { return } + await withCheckedContinuation { continuation in + enqueueOrResume(continuation) + } + } + + func release() { + stateLock.lock() + if waiters.isEmpty { + isLocked = false + stateLock.unlock() + } else { + let next = waiters.removeFirst() + stateLock.unlock() + next.resume() + } + } + + private func tryAcquireImmediately() -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + if !isLocked { + isLocked = true + return true + } + return false + } + + private func enqueueOrResume(_ continuation: CheckedContinuation) { + stateLock.lock() + if !isLocked { + isLocked = true + stateLock.unlock() + continuation.resume() + } else { + waiters.append(continuation) + stateLock.unlock() + } + } +} + +final class MSSQLKerberosConnectGate: @unchecked Sendable { + static let shared = MSSQLKerberosConnectGate() + + private let lock = AsyncLock() + + func connect(_ conn: FreeTDSConnection, principal: String, password: String) async throws { + await lock.acquire() + defer { lock.release() } + + guard !principal.isEmpty, !password.isEmpty else { + try await conn.connect() + return + } + + let cache = try MSSQLKerberosCredentials.acquireTicket(principal: principal, password: password) + defer { cache.destroy() } + + let previous = getenv("KRB5CCNAME").map { String(cString: $0) } + setenv("KRB5CCNAME", cache.name, 1) + defer { + if let previous { + setenv("KRB5CCNAME", previous, 1) + } else { + unsetenv("KRB5CCNAME") + } + } + + try await conn.connect() + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLKerberosCredentials.swift b/Plugins/MSSQLDriverPlugin/MSSQLKerberosCredentials.swift new file mode 100644 index 000000000..b4c21c1a2 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLKerberosCredentials.swift @@ -0,0 +1,80 @@ +import Foundation +import GSS +import TableProMSSQLCore + +struct MSSQLKerberosCache { + let name: String + let filePath: String + + func destroy() { + try? FileManager.default.removeItem(atPath: filePath) + } +} + +enum MSSQLKerberosCredentials { + static func acquireTicket(principal: String, password: String) throws -> MSSQLKerberosCache { + let fileName = "tablepro-krb5-\(UUID().uuidString)" + let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName) + let cacheName = "FILE:\(filePath)" + + let importedName = try importName(principal) + defer { + var releaseMinor: OM_uint32 = 0 + var releasable: gss_name_t? = importedName + _ = gss_release_name(&releaseMinor, &releasable) + } + + let attributes = NSMutableDictionary() + attributes[kGSSICPassword] = password + attributes[kGSSICKerberosCacheName] = cacheName + + var cred: gss_cred_id_t? + var errorRef: Unmanaged? + let status = gss_aapl_initial_cred( + importedName, + &__gss_krb5_mechanism_oid_desc, + attributes as CFDictionary, + &cred, + &errorRef + ) + + guard status == 0 else { + let message = errorRef?.takeRetainedValue().localizedDescription + ?? String(localized: "Kerberos ticket request failed") + try? FileManager.default.removeItem(atPath: filePath) + throw MSSQLCoreError.kerberosAuthFailed( + kind: MSSQLKerberosClassifier.classify(message) ?? .wrongPassword, + serverMessage: message + ) + } + + if cred != nil { + var releaseMinor: OM_uint32 = 0 + _ = gss_release_cred(&releaseMinor, &cred) + } + + return MSSQLKerberosCache(name: cacheName, filePath: filePath) + } + + private static func importName(_ principal: String) throws -> gss_name_t { + var minor: OM_uint32 = 0 + var name: gss_name_t? + let status = principal.withCString { cString -> OM_uint32 in + var buffer = gss_buffer_desc( + length: strlen(cString), + value: UnsafeMutableRawPointer(mutating: cString) + ) + return gss_import_name(&minor, &buffer, &__gss_c_nt_user_name_oid_desc, &name) + } + guard status == 0, let name else { + throw MSSQLCoreError.kerberosAuthFailed( + kind: .principalUnknown, + serverMessage: String( + format: String(localized: "Invalid Kerberos principal: %@"), + principal + ) + ) + } + return name + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 1fcdfdc5a..f400e0d94 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -46,6 +46,8 @@ private extension MSSQLPluginError { self = .queryFailed(String(localized: "Query was cancelled")) case .tlsHandshakeFailed(_, let serverMessage): self = .connectionFailed(String(format: String(localized: "TLS: %@"), serverMessage)) + case let .kerberosAuthFailed(kind, serverMessage): + self = .connectionFailed(MSSQLKerberosMessage.describe(kind: kind, serverMessage: serverMessage)) } } } @@ -65,7 +67,7 @@ private extension MSSQLTLSFailureKind { final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "MSSQL Driver" - static let pluginVersion = "1.0.0" + static let pluginVersion = "1.1.0" static let pluginDescription = "Microsoft SQL Server support via FreeTDS db-lib" static let capabilities: [PluginCapability] = [.databaseDriver] @@ -74,6 +76,37 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let iconName = "mssql-icon" static let defaultPort = 1433 static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: MSSQLConnectionOptions.AdditionalFieldKey.authMethod, + label: String(localized: "Authentication"), + defaultValue: "sql", + fieldType: .dropdown(options: [ + .init(value: "sql", label: "SQL Server Authentication"), + .init(value: "windows", label: "Windows Authentication (Kerberos)") + ]), + section: .authentication + ), + ConnectionField( + id: MSSQLKerberosField.principal, + label: String(localized: "Kerberos Principal"), + placeholder: "user@REALM.COM", + section: .authentication, + visibleWhen: FieldVisibilityRule( + fieldId: MSSQLConnectionOptions.AdditionalFieldKey.authMethod, + values: ["windows"] + ) + ).withHidesUsername(true), + ConnectionField( + id: MSSQLKerberosField.password, + label: String(localized: "Password"), + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule( + fieldId: MSSQLConnectionOptions.AdditionalFieldKey.authMethod, + values: ["windows"] + ) + ), ConnectionField(id: "mssqlSchema", label: "Schema", placeholder: "dbo", defaultValue: "dbo") ] @@ -229,6 +262,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Connection func connect() async throws { + let authMethod = MSSQLConnectionOptions.authMethod(from: config.additionalFields) let options = MSSQLConnectionOptions( host: config.host, port: config.port, @@ -236,16 +270,23 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { password: config.password, database: config.database, schema: _currentSchema, - encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode) + encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode), + authMethod: authMethod ) let conn = FreeTDSConnection(options: options) do { - try await conn.connect() + try await establishConnection(conn, authMethod: authMethod) } catch let error as MSSQLCoreError { - if case let .tlsHandshakeFailed(kind, serverMessage) = error { + switch error { + case let .tlsHandshakeFailed(kind, serverMessage): throw kind.sslHandshakeError(serverMessage: serverMessage) + case let .kerberosAuthFailed(kind, serverMessage): + throw MSSQLPluginError.connectionFailed( + MSSQLKerberosMessage.describe(kind: kind, serverMessage: serverMessage) + ) + default: + throw MSSQLPluginError(coreError: error) } - throw MSSQLPluginError(coreError: error) } self.freeTDSConn = conn @@ -268,6 +309,17 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + private func establishConnection(_ conn: FreeTDSConnection, authMethod: MSSQLAuthMethod) async throws { + guard authMethod == .windows else { + try await conn.connect() + return + } + let principal = (config.additionalFields[MSSQLKerberosField.principal] ?? "") + .trimmingCharacters(in: .whitespaces) + let password = config.additionalFields[MSSQLKerberosField.password] ?? "" + try await MSSQLKerberosConnectGate.shared.connect(conn, principal: principal, password: password) + } + private func executeInternal(_ query: String) async throws -> PluginQueryResult { guard let conn = freeTDSConn else { throw MSSQLPluginError.notConnected @@ -734,6 +786,44 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } +// MARK: - Kerberos + +enum MSSQLKerberosField { + static let principal = "mssqlKerberosPrincipal" + static let password = "mssqlKerberosPassword" +} + +enum MSSQLKerberosMessage { + static func describe(kind: MSSQLKerberosFailureKind, serverMessage: String) -> String { + let summary: String + let suggestion: String + switch kind { + case .noCredential: + summary = String(localized: "No Kerberos ticket was found.") + suggestion = String(localized: "Run kinit user@REALM.COM in Terminal, or enter a Kerberos principal and password, then reconnect.") + case .principalUnknown: + summary = String(localized: "The Kerberos principal is unknown to the domain.") + suggestion = String(localized: "Check the principal spelling and realm, for example user@REALM.COM with the realm in uppercase.") + case .wrongPassword: + summary = String(localized: "The Kerberos password was rejected.") + suggestion = String(localized: "Re-enter the domain password for this principal.") + case .spnNotFound: + summary = String(localized: "The SQL Server Kerberos service principal name is not registered.") + suggestion = String(localized: "Ask your administrator to register the SPN, for example MSSQLSvc/host.domain.com:1433, and connect by hostname rather than IP address.") + case .clockSkew: + summary = String(localized: "This Mac's clock is too far out of sync with the domain controller.") + suggestion = String(localized: "Turn on Set time automatically in System Settings > General > Date & Time, then reconnect.") + case .realmNotResolved: + summary = String(localized: "The Kerberos realm could not be resolved.") + suggestion = String(localized: "Confirm this network resolves the domain, or add the realm to /etc/krb5.conf.") + case .ticketExpired: + summary = String(localized: "The Kerberos ticket has expired.") + suggestion = String(localized: "Run kinit user@REALM.COM to renew it, then reconnect.") + } + return "\(summary) \(suggestion) (\(serverMessage))" + } +} + // MARK: - Errors enum MSSQLPluginError: Error { diff --git a/Plugins/TableProPluginKit/ConnectionField.swift b/Plugins/TableProPluginKit/ConnectionField.swift index 228a4d37d..3afd67985 100644 --- a/Plugins/TableProPluginKit/ConnectionField.swift +++ b/Plugins/TableProPluginKit/ConnectionField.swift @@ -97,6 +97,7 @@ public struct ConnectionField: Codable, Sendable { public let hidesPassword: Bool public let visibleWhen: FieldVisibilityRule? public var dynamicOptions: DynamicFieldOptions? + public var hidesUsername: Bool = false /// Backward-compatible convenience: true when fieldType is .secure public var isSecure: Bool { @@ -133,6 +134,12 @@ public struct ConnectionField: Codable, Sendable { return copy } + public func withHidesUsername(_ hides: Bool) -> ConnectionField { + var copy = self + copy.hidesUsername = hides + return copy + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) @@ -145,10 +152,11 @@ public struct ConnectionField: Codable, Sendable { hidesPassword = try container.decodeIfPresent(Bool.self, forKey: .hidesPassword) ?? false visibleWhen = try container.decodeIfPresent(FieldVisibilityRule.self, forKey: .visibleWhen) dynamicOptions = try container.decodeIfPresent(DynamicFieldOptions.self, forKey: .dynamicOptions) + hidesUsername = try container.decodeIfPresent(Bool.self, forKey: .hidesUsername) ?? false } private enum CodingKeys: String, CodingKey { case id, label, placeholder, isRequired, defaultValue, fieldType, section, hidesPassword, visibleWhen - case dynamicOptions + case dynamicOptions, hidesUsername } } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 7cb8f8ad4..a249ff596 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -3581,13 +3581,15 @@ "$(PROJECT_DIR)/Libs/dylibs", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; OTHER_LDFLAGS = ( "-force_load", "$(PROJECT_DIR)/Libs/libsybdb.a", "-lssl.3", "-lcrypto.3", "-liconv", + "-framework", + "GSS", ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MSSQLDriver; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -3620,13 +3622,15 @@ "$(PROJECT_DIR)/Libs/dylibs", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; OTHER_LDFLAGS = ( "-force_load", "$(PROJECT_DIR)/Libs/libsybdb.a", "-lssl.3", "-lcrypto.3", "-liconv", + "-framework", + "GSS", ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MSSQLDriver; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/TablePro/Core/Plugins/ConnectionField+PasswordHiding.swift b/TablePro/Core/Plugins/ConnectionField+PasswordHiding.swift index 4e2f3023d..36bd8762c 100644 --- a/TablePro/Core/Plugins/ConnectionField+PasswordHiding.swift +++ b/TablePro/Core/Plugins/ConnectionField+PasswordHiding.swift @@ -7,8 +7,19 @@ import TableProPluginKit extension Sequence where Element == ConnectionField { func hidesPassword(forValues values: [String: String]) -> Bool { + hidesBuiltInField(forValues: values, when: \.hidesPassword) + } + + func hidesUsername(forValues values: [String: String]) -> Bool { + hidesBuiltInField(forValues: values, when: \.hidesUsername) + } + + private func hidesBuiltInField( + forValues values: [String: String], + when flag: (ConnectionField) -> Bool + ) -> Bool { contains { field in - guard field.section == .authentication, field.hidesPassword else { return false } + guard field.section == .authentication, flag(field) else { return false } switch field.fieldType { case .toggle: return values[field.id] == "true" diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 35dc6b475..244e7ee0f 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -201,6 +201,31 @@ extension PluginMetadataRegistry { ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: [ + ConnectionField( + id: "mssqlAuthMethod", + label: String(localized: "Authentication"), + defaultValue: "sql", + fieldType: .dropdown(options: [ + .init(value: "sql", label: "SQL Server Authentication"), + .init(value: "windows", label: "Windows Authentication (Kerberos)") + ]), + section: .authentication + ), + ConnectionField( + id: "mssqlKerberosPrincipal", + label: String(localized: "Kerberos Principal"), + placeholder: "user@REALM.COM", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "mssqlAuthMethod", values: ["windows"]) + ).withHidesUsername(true), + ConnectionField( + id: "mssqlKerberosPassword", + label: String(localized: "Password"), + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "mssqlAuthMethod", values: ["windows"]) + ), ConnectionField( id: "mssqlSchema", label: "Schema", placeholder: "dbo", defaultValue: "dbo" ) diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 1c19a1381..b90e967ee 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -4,6 +4,7 @@ // import AppKit +import Network import SwiftUI import TableProPluginKit import UniformTypeIdentifiers @@ -223,7 +224,7 @@ struct GeneralPaneView: View { private var authenticationSection: some View { if connectionMode != .fileBased { Section(String(localized: "Authentication")) { - if connectionMode == .network { + if connectionMode == .network && !coordinator.auth.hidesUsername { TextField( String(localized: "Username"), text: $coordinator.auth.username @@ -253,6 +254,7 @@ struct GeneralPaneView: View { } } } + kerberosCaption if coordinator.auth.usePgpass { pgpassStatusView } @@ -296,6 +298,32 @@ struct GeneralPaneView: View { } } + @ViewBuilder + private var kerberosCaption: some View { + if type.pluginTypeId == "SQL Server", + coordinator.auth.additionalFieldValues["mssqlAuthMethod"] == "windows" { + Label( + String(localized: "Leave the principal and password blank to use your existing Kerberos ticket. Run kinit user@REALM.COM in Terminal first if you don't have one."), + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + if hostIsIPAddress { + Label( + String(localized: "Windows Authentication needs the server's hostname, not an IP address. Kerberos service principals aren't registered against IP addresses."), + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.yellow) + } + } + } + + private var hostIsIPAddress: Bool { + let host = coordinator.network.resolvedHost.trimmingCharacters(in: .whitespaces) + return IPv4Address(host) != nil || IPv6Address(host) != nil + } + private func isHostListField(_ field: ConnectionField) -> Bool { if case .hostList = field.fieldType { return true } return false diff --git a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift index 2085177ec..9b526ed08 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift @@ -59,6 +59,14 @@ final class AuthPaneViewModel { .hidesPassword(forValues: additionalFieldValues) } + var hidesUsername: Bool { + guard let type = coordinator?.value?.network.type else { + return authFields.hidesUsername(forValues: additionalFieldValues) + } + return PluginManager.shared.additionalConnectionFields(for: type) + .hidesUsername(forValues: additionalFieldValues) + } + var effectivePromptForPassword: Bool { promptForPassword && !hidesPassword } diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index 91448c7b3..2de59da80 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -21,6 +21,7 @@ private extension MSSQLRawResult { final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { private let conn: FreeTDSConnection private let host: String + private let authMethod: MSSQLAuthMethod var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } @@ -29,6 +30,7 @@ final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { nonisolated(unsafe) private(set) var serverVersion: String? init(connection: DatabaseConnection, password: String?) { + let authMethod = MSSQLConnectionOptions.authMethod(from: connection.additionalFields) let options = MSSQLConnectionOptions( host: connection.host, port: connection.port, @@ -40,10 +42,12 @@ final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration ).freetdsEncryptionFlag, - loginTimeoutSeconds: Int(connection.additionalFields["mssqlLoginTimeout"] ?? "") ?? MSSQLConnectionOptions.defaultLoginTimeoutSeconds + loginTimeoutSeconds: Int(connection.additionalFields["mssqlLoginTimeout"] ?? "") ?? MSSQLConnectionOptions.defaultLoginTimeoutSeconds, + authMethod: authMethod ) self.conn = FreeTDSConnection(options: options) self.host = connection.host + self.authMethod = authMethod self.currentSchema = options.schema } @@ -54,6 +58,9 @@ final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Connection func connect() async throws { + guard authMethod != .windows else { + throw DatabaseError(message: String(localized: "Windows Authentication (Kerberos) isn't supported on iOS yet. Use SQL Server Authentication, or connect from the Mac app.")) + } try await LocalNetworkPermission.shared.ensureAccess(for: host) do { try await conn.connect() @@ -271,6 +278,8 @@ final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { return DatabaseError(message: msg) case .tlsHandshakeFailed(_, let serverMessage): return DatabaseError(message: "TLS handshake failed: \(serverMessage)") + case .kerberosAuthFailed(_, let serverMessage): + return DatabaseError(message: "Kerberos authentication failed: \(serverMessage)") case .queryFailed(let msg): return DatabaseError(message: msg) case .cancelled: diff --git a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverWindowsAuthTests.swift b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverWindowsAuthTests.swift new file mode 100644 index 000000000..06c1b08ac --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverWindowsAuthTests.swift @@ -0,0 +1,40 @@ +import XCTest +import TableProDatabase +import TableProModels +@testable import TableProMobile + +/// Deterministic tests for how the iOS MSSQL driver treats Windows Authentication. +/// Kerberos is macOS only; the iOS FreeTDS build has no GSS support, so the driver must +/// reject a synced Windows-auth connection before any network access. No server needed. +final class MSSQLDriverWindowsAuthTests: XCTestCase { + private func connection(authMethod: String) -> DatabaseConnection { + DatabaseConnection( + name: "kerberos", + type: .mssql, + host: "sql.contoso.com", + port: 1433, + username: "", + database: "master", + additionalFields: ["mssqlAuthMethod": authMethod] + ) + } + + func testWindowsAuthIsRejectedBeforeConnecting() async { + let driver = MSSQLDriver(connection: connection(authMethod: "windows"), password: nil) + do { + try await driver.connect() + XCTFail("Windows Authentication should be rejected on iOS") + } catch let error as DatabaseError { + XCTAssertTrue( + error.message.contains("Windows Authentication"), + "Expected the Windows-auth rejection, got: \(error.message)" + ) + } catch { + XCTFail("Expected a DatabaseError, got \(error)") + } + } + + func testSqlAuthDriverConstructsWithoutRejection() { + _ = MSSQLDriver(connection: connection(authMethod: "sql"), password: "secret") + } +} diff --git a/TableProTests/Core/Plugins/PasswordHidingTests.swift b/TableProTests/Core/Plugins/PasswordHidingTests.swift index 0eab2d746..c8476bc72 100644 --- a/TableProTests/Core/Plugins/PasswordHidingTests.swift +++ b/TableProTests/Core/Plugins/PasswordHidingTests.swift @@ -117,6 +117,51 @@ struct PasswordHidingTests { } } +@Suite("Username hiding from connection fields") +struct UsernameHidingTests { + private func mssqlAuthFields() -> [ConnectionField] { + [ + ConnectionField( + id: "mssqlAuthMethod", + label: "Authentication", + defaultValue: "sql", + fieldType: .dropdown(options: [ + .init(value: "sql", label: "SQL Server Authentication"), + .init(value: "windows", label: "Windows Authentication (Kerberos)"), + ]), + section: .authentication + ), + ConnectionField( + id: "mssqlKerberosPrincipal", + label: "Kerberos Principal", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "mssqlAuthMethod", values: ["windows"]) + ).withHidesUsername(true), + ] + } + + @Test("Windows auth hides the built-in username, SQL auth does not") + func windowsHidesUsername() { + let fields = mssqlAuthFields() + #expect(fields.hidesUsername(forValues: [:]) == false) + #expect(fields.hidesUsername(forValues: ["mssqlAuthMethod": "sql"]) == false) + #expect(fields.hidesUsername(forValues: ["mssqlAuthMethod": "windows"]) == true) + } + + @Test("hidesUsername is independent of hidesPassword") + func usernameHidingIsIndependent() { + let fields = mssqlAuthFields() + #expect(fields.hidesPassword(forValues: ["mssqlAuthMethod": "windows"]) == false) + } + + @Test("Fields without the hidesUsername flag never hide the username") + func plainFieldsDoNotHide() { + let plain = ConnectionField(id: "region", label: "Region", section: .authentication) + #expect([plain].hidesUsername(forValues: ["region": "us-east-1"]) == false) + #expect([ConnectionField]().hidesUsername(forValues: [:]) == false) + } +} + @Suite("Password hiding resolved from plugin metadata") @MainActor struct PluginManagerPasswordHidingTests { diff --git a/TableProTests/Plugins/MSSQLLoginParametersTests.swift b/TableProTests/Plugins/MSSQLLoginParametersTests.swift index 8489d6826..38582560e 100644 --- a/TableProTests/Plugins/MSSQLLoginParametersTests.swift +++ b/TableProTests/Plugins/MSSQLLoginParametersTests.swift @@ -43,4 +43,17 @@ struct MSSQLLoginParametersTests { let parameters = build(database: "tmsdevdb1") #expect(parameters.contains(MSSQLLoginParameter(field: .nationalLanguage, value: "us_english"))) } + + @Test("an empty username still emits a user parameter, the FreeTDS Kerberos/GSS trigger") + func emptyUserEmitsEmptyUserParameter() { + let parameters = MSSQLLoginParameters.build( + user: "", + password: "", + applicationName: "TablePro", + encryptionFlag: "require", + database: "app" + ) + #expect(parameters.contains(MSSQLLoginParameter(field: .user, value: ""))) + #expect(parameters.contains(MSSQLLoginParameter(field: .password, value: ""))) + } } diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index dcdaddf1a..9972aab48 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -25,7 +25,8 @@ Click **New Connection**, select **SQL Server**, fill in the fields, and click * |-------|---------|-------| | **Host** | `localhost` | Named instances are not supported; use the host and TCP port | | **Port** | `1433` | | -| **Username** | - | SQL Server Authentication only, no Windows Auth | +| **Authentication** | `SQL Server Authentication` | Or **Windows Authentication (Kerberos)** on macOS. See [Windows Authentication](#windows-authentication-kerberos) below | +| **Username** | - | SQL Server login name. Hidden when Windows Authentication is selected | | **Database** | - | Optional. Sent during login when set, which is required for logins scoped to one database, such as Azure SQL contained users | | **Schema** | `dbo` | Active schema after connecting. Clear it to use the login's default schema, which the driver reads with `SELECT SCHEMA_NAME()` | @@ -36,6 +37,30 @@ Click **New Connection**, select **SQL Server**, fill in the fields, and click * For Azure SQL Database, set the host to `yourserver.database.windows.net` and pick SSL mode **Required** or stricter. +## Windows Authentication (Kerberos) + +Available on macOS. Set **Authentication** to **Windows Authentication (Kerberos)** to sign in with your domain identity instead of a SQL Server login. On macOS, Windows Authentication means Kerberos; NTLM is not supported. + +There are two ways to sign in: + +- **Use your existing ticket (single sign-on).** Leave the Kerberos principal and password blank. TablePro uses the ticket already in your credential cache. Get one first with `kinit`: + + ```bash + kinit user@REALM.COM + klist # confirm a krbtgt/REALM.COM@REALM.COM ticket exists + ``` + +- **Sign in with a principal and password.** Enter the Kerberos principal (`user@REALM.COM`) and your domain password. TablePro requests a ticket for that principal, then connects. + +Requirements: + +- **Connect by hostname, not IP address.** Kerberos targets the service principal name `MSSQLSvc/host.domain.com:1433`, which is registered against the host name. +- **Uppercase realm.** The realm is usually the DNS domain in uppercase, for example `CONTOSO.COM`. +- **Realm resolution.** Your Mac must be able to find the domain's KDC, either through DNS SRV records or an `/etc/krb5.conf` entry. +- **A registered SPN.** The SQL Server service account must have an SPN such as `MSSQLSvc/host.domain.com:1433`. Ask your administrator if connections fail with an SSPI or service-principal error. + +TablePro does not run `kinit` or edit `/etc/krb5.conf` for you; those are set up once per machine. + ## Connection URL ```text @@ -82,4 +107,6 @@ SQL Server connections can run through the Cloud SQL Auth Proxy; TablePro starts **Login failed**: verify credentials, then check the server allows SQL Server Authentication: `SELECT SERVERPROPERTY('IsIntegratedSecurityOnly')` returns `1` when only Windows Authentication is allowed. Enable mixed mode in SSMS under Server Properties > Security, then restart the service. If the login only has access to one database (an Azure SQL contained user), set the **Database** field; TablePro sends it during login. Without it the server authenticates against `master` and rejects the login. -**Limitations**: SQL Server Authentication only (no Windows or Entra ID auth), named instances unsupported (use host and port), Verify CA and Verify Identity behave like Required. +**Windows Authentication fails**: run `klist` to confirm you have a ticket, and `kinit user@REALM.COM` if you do not. Connect by hostname, not IP address. An SSPI or "server not found in Kerberos database" error means the server's SPN is not registered; ask your administrator to register `MSSQLSvc/host.domain.com:1433`. A clock-skew error means this Mac's clock is too far from the domain controller; turn on **Set time automatically** in System Settings. + +**Limitations**: no Microsoft Entra ID (Azure AD) authentication, Windows Authentication (Kerberos) is macOS only, named instances unsupported (use host and port), Verify CA and Verify Identity behave like Required. diff --git a/scripts/build-freetds.sh b/scripts/build-freetds.sh index 7912be093..b338807aa 100755 --- a/scripts/build-freetds.sh +++ b/scripts/build-freetds.sh @@ -55,6 +55,7 @@ build_slice() { local HOST_TRIPLE="$4" local VERSION_FLAG="$5" local OPENSSL_PREFIX="$6" + local KRB5_FLAG="${7:-}" local PREFIX="/tmp/freetds-${SLICE_LABEL}" local SDKPATH @@ -89,6 +90,7 @@ build_slice() { --disable-libiconv \ --with-tdsver=7.4 \ --with-openssl="$OPENSSL_PREFIX" \ + ${KRB5_FLAG} \ CC="$CC_BIN" \ CFLAGS="-arch ${ARCH} -isysroot ${SDKPATH} ${VERSION_FLAG} -I${OPENSSL_PREFIX}/include" \ LDFLAGS="-arch ${ARCH} -isysroot ${SDKPATH} -L${OPENSSL_PREFIX}/lib" @@ -120,8 +122,11 @@ cp "$LIBS_DIR/libcrypto_arm64.a" "$MACOS_OPENSSL_ARM64/lib/libcrypto.a" cp "$LIBS_DIR/libssl_x86_64.a" "$MACOS_OPENSSL_X86_64/lib/libssl.a" cp "$LIBS_DIR/libcrypto_x86_64.a" "$MACOS_OPENSSL_X86_64/lib/libcrypto.a" -build_slice "macos-arm64" "macosx" "arm64" "aarch64-apple-darwin" "-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}" "$MACOS_OPENSSL_ARM64" -build_slice "macos-x86_64" "macosx" "x86_64" "x86_64-apple-darwin" "-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}" "$MACOS_OPENSSL_X86_64" +# macOS slices enable Kerberos/GSS (Windows Authentication). --enable-krb5 links the system +# Heimdal GSS via the SDK libgssapi_krb5 stub (Kerberos.framework); the plugin adds -framework GSS +# at link time to resolve the symbols. iOS slices stay krb5-free (Windows auth is macOS only). +build_slice "macos-arm64" "macosx" "arm64" "aarch64-apple-darwin" "-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}" "$MACOS_OPENSSL_ARM64" "--enable-krb5" +build_slice "macos-x86_64" "macosx" "x86_64" "x86_64-apple-darwin" "-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}" "$MACOS_OPENSSL_X86_64" "--enable-krb5" # iOS slices link OpenSSL statically from the existing xcframeworks; reconstruct a unix-style prefix # for FreeTDS's --with-openssl which expects include/ and lib/ siblings. @@ -145,6 +150,16 @@ lipo -create \ "$LIBS_DIR/libsybdb_macos-x86_64.a" \ -output "$LIBS_DIR/libsybdb_macos_universal.a" +# Write the flat committed archives the macOS plugin links directly (TablePro.xcodeproj +# force_loads Libs/libsybdb.a). The per-slice intermediates below are deleted after the +# xcframework is assembled, so produce these before that cleanup runs. Publish them with +# scripts/publish-libs.sh libsybdb_arm64.a libsybdb_x86_64.a libsybdb_universal.a libsybdb.a +echo "==> Writing committed macOS archives (libsybdb.a, libsybdb_arm64.a, libsybdb_x86_64.a, libsybdb_universal.a)..." +cp "$LIBS_DIR/libsybdb_macos-arm64.a" "$LIBS_DIR/libsybdb_arm64.a" +cp "$LIBS_DIR/libsybdb_macos-x86_64.a" "$LIBS_DIR/libsybdb_x86_64.a" +cp "$LIBS_DIR/libsybdb_macos_universal.a" "$LIBS_DIR/libsybdb_universal.a" +cp "$LIBS_DIR/libsybdb_macos_universal.a" "$LIBS_DIR/libsybdb.a" + HEADERS_STAGE="$BUILD_DIR/headers-stage" rm -rf "$HEADERS_STAGE" mkdir -p "$HEADERS_STAGE"