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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

public enum MSSQLAuthMethod: String, Sendable, Equatable {
case sqlServer = "sql"
case windows
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
init(options: MSSQLConnectionOptions) {
self.options = options
self.queue = DispatchQueue(label: "com.TablePro.freetds.\(options.host).\(options.port)", qos: .userInitiated)
_ = freetdsInitOnce

Check warning on line 136 in Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift

View workflow job for this annotation

GitHub Actions / Run iOS Tests

main actor-isolated let 'freetdsInitOnce' can not be referenced from a nonisolated context
}

func connect() async throws {
Expand All @@ -148,7 +148,7 @@
}
defer { dbloginfree(login) }

for parameter in MSSQLLoginParameters.build(

Check warning on line 151 in Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift

View workflow job for this annotation

GitHub Actions / Run iOS Tests

call to main actor-isolated static method 'build(user:password:applicationName:encryptionFlag:database:)' in a synchronous nonisolated context
user: options.user,
password: options.password,
applicationName: options.applicationName,
Expand All @@ -172,6 +172,9 @@
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)")
}

Expand Down Expand Up @@ -218,7 +221,7 @@
if let handle {
freetdsUnregister(handle)
queue.async {
_ = dbclose(handle)

Check warning on line 224 in Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

capture of 'handle' with non-Sendable type 'UnsafeMutablePointer<DBPROCESS>' (aka 'UnsafeMutablePointer<dbprocess>') in a '@sendable' closure

Check warning on line 224 in Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

capture of 'handle' with non-Sendable type 'UnsafeMutablePointer<DBPROCESS>' (aka 'UnsafeMutablePointer<dbprocess>') in a '@sendable' closure
}
}
}
Expand Down
80 changes: 80 additions & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLKerberosConnectGate.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>] = []

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<Void, Never>) {
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()
}
}
80 changes: 80 additions & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLKerberosCredentials.swift
Original file line number Diff line number Diff line change
@@ -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<CFError>?
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
}
}
Loading
Loading