Skip to content

Commit b91c731

Browse files
authored
feat(plugin-mssql): add Windows Authentication (Kerberos) on macOS (#1890)
1 parent 08b7fbd commit b91c731

23 files changed

Lines changed: 665 additions & 18 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- 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)
1213
- 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)
1314

1415
### Fixed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Foundation
2+
3+
public enum MSSQLAuthMethod: String, Sendable, Equatable {
4+
case sqlServer = "sql"
5+
case windows
6+
}

Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable {
1010
public var encryptionFlag: String
1111
public var applicationName: String
1212
public var loginTimeoutSeconds: Int
13+
public var authMethod: MSSQLAuthMethod
1314

1415
public static let defaultPort = 1433
1516
public static let defaultSchema = "dbo"
@@ -26,12 +27,20 @@ public struct MSSQLConnectionOptions: Sendable, Equatable {
2627
schema: String = MSSQLConnectionOptions.defaultSchema,
2728
encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag,
2829
applicationName: String = MSSQLConnectionOptions.defaultApplicationName,
29-
loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds
30+
loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds,
31+
authMethod: MSSQLAuthMethod = .sqlServer
3032
) {
3133
self.host = host
3234
self.port = port
33-
self.user = user
34-
self.password = password
35+
self.authMethod = authMethod
36+
switch authMethod {
37+
case .sqlServer:
38+
self.user = user
39+
self.password = password
40+
case .windows:
41+
self.user = ""
42+
self.password = ""
43+
}
3544
self.database = database
3645
self.schema = schema
3746
self.encryptionFlag = encryptionFlag
@@ -43,10 +52,15 @@ public struct MSSQLConnectionOptions: Sendable, Equatable {
4352
public extension MSSQLConnectionOptions {
4453
enum AdditionalFieldKey {
4554
public static let schema = "mssqlSchema"
55+
public static let authMethod = "mssqlAuthMethod"
4656
}
4757

4858
static func schema(from additionalFields: [String: String]) -> String {
4959
let raw = additionalFields[AdditionalFieldKey.schema] ?? ""
5060
return raw.isEmpty ? defaultSchema : raw
5161
}
62+
63+
static func authMethod(from additionalFields: [String: String]) -> MSSQLAuthMethod {
64+
MSSQLAuthMethod(rawValue: additionalFields[AdditionalFieldKey.authMethod] ?? "") ?? .sqlServer
65+
}
5266
}

Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,23 @@ public enum MSSQLTLSFailureKind: Sendable {
99
case cipherMismatch
1010
}
1111

12+
public enum MSSQLKerberosFailureKind: Sendable, Equatable {
13+
case noCredential
14+
case principalUnknown
15+
case wrongPassword
16+
case spnNotFound
17+
case clockSkew
18+
case realmNotResolved
19+
case ticketExpired
20+
}
21+
1222
public enum MSSQLCoreError: LocalizedError, Sendable {
1323
case connectionFailed(String)
1424
case notConnected
1525
case queryFailed(String)
1626
case cancelled
1727
case tlsHandshakeFailed(kind: MSSQLTLSFailureKind, serverMessage: String)
28+
case kerberosAuthFailed(kind: MSSQLKerberosFailureKind, serverMessage: String)
1829

1930
public var errorDescription: String? {
2031
switch self {
@@ -28,6 +39,8 @@ public enum MSSQLCoreError: LocalizedError, Sendable {
2839
return String(localized: "Query was cancelled")
2940
case .tlsHandshakeFailed(_, let serverMessage):
3041
return String(format: String(localized: "TLS handshake failed: %@"), serverMessage)
42+
case .kerberosAuthFailed(_, let serverMessage):
43+
return String(format: String(localized: "Kerberos authentication failed: %@"), serverMessage)
3144
}
3245
}
3346
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Foundation
2+
3+
public enum MSSQLKerberosClassifier {
4+
public static func classify(_ message: String) -> MSSQLKerberosFailureKind? {
5+
let lower = message.lowercased()
6+
if lower.contains("clock skew") {
7+
return .clockSkew
8+
}
9+
if lower.contains("ticket expired") || lower.contains("credentials have expired") {
10+
return .ticketExpired
11+
}
12+
if lower.contains("no credentials cache") || lower.contains("no valid credentials")
13+
|| lower.contains("no credential") {
14+
return .noCredential
15+
}
16+
if lower.contains("preauthentication failed") || lower.contains("password incorrect")
17+
|| lower.contains("integrity check failed") {
18+
return .wrongPassword
19+
}
20+
if lower.contains("client not found in kerberos database")
21+
|| lower.contains("client's entry in database has expired") {
22+
return .principalUnknown
23+
}
24+
if lower.contains("server not found in kerberos database") {
25+
return .spnNotFound
26+
}
27+
if lower.contains("cannot find kdc") || lower.contains("cannot resolve network address")
28+
|| lower.contains("unable to reach any kdc") || lower.contains("cannot determine realm")
29+
|| lower.contains("cannot locate default realm") {
30+
return .realmNotResolved
31+
}
32+
return nil
33+
}
34+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Testing
2+
@testable import TableProMSSQLCore
3+
4+
@Suite("MSSQL auth method")
5+
struct MSSQLConnectionOptionsAuthMethodTests {
6+
@Test("SQL Server auth passes username and password through")
7+
func sqlServerKeepsCredentials() {
8+
let options = MSSQLConnectionOptions(
9+
host: "db.example.com",
10+
user: "sa",
11+
password: "hunter2",
12+
database: "app",
13+
authMethod: .sqlServer
14+
)
15+
#expect(options.user == "sa")
16+
#expect(options.password == "hunter2")
17+
#expect(options.authMethod == .sqlServer)
18+
}
19+
20+
@Test("Windows auth blanks username and password so FreeTDS takes the GSS path")
21+
func windowsBlanksCredentials() {
22+
let options = MSSQLConnectionOptions(
23+
host: "db.example.com",
24+
user: "sa",
25+
password: "hunter2",
26+
database: "app",
27+
authMethod: .windows
28+
)
29+
#expect(options.user == "")
30+
#expect(options.password == "")
31+
#expect(options.authMethod == .windows)
32+
}
33+
34+
@Test("Default auth method is SQL Server")
35+
func defaultsToSqlServer() {
36+
let options = MSSQLConnectionOptions(host: "h", user: "u", password: "p", database: "d")
37+
#expect(options.authMethod == .sqlServer)
38+
}
39+
40+
@Test("authMethod(from:) resolves the additional field, defaulting to SQL Server")
41+
func resolvesFromAdditionalFields() {
42+
#expect(MSSQLConnectionOptions.authMethod(from: ["mssqlAuthMethod": "windows"]) == .windows)
43+
#expect(MSSQLConnectionOptions.authMethod(from: ["mssqlAuthMethod": "sql"]) == .sqlServer)
44+
#expect(MSSQLConnectionOptions.authMethod(from: [:]) == .sqlServer)
45+
#expect(MSSQLConnectionOptions.authMethod(from: ["mssqlAuthMethod": "nonsense"]) == .sqlServer)
46+
}
47+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Testing
2+
@testable import TableProMSSQLCore
3+
4+
@Suite("MSSQL Kerberos Classifier")
5+
struct MSSQLKerberosClassifierTests {
6+
@Test("No credentials cache → noCredential")
7+
func noCredential() {
8+
#expect(MSSQLKerberosClassifier.classify("gss_init_sec_context: No credentials cache found") == .noCredential)
9+
}
10+
11+
@Test("Preauthentication failed → wrongPassword")
12+
func wrongPassword() {
13+
#expect(MSSQLKerberosClassifier.classify("krb5: Preauthentication failed") == .wrongPassword)
14+
}
15+
16+
@Test("Client not found in Kerberos database → principalUnknown")
17+
func principalUnknown() {
18+
#expect(MSSQLKerberosClassifier.classify("Client not found in Kerberos database") == .principalUnknown)
19+
}
20+
21+
@Test("Server not found in Kerberos database → spnNotFound")
22+
func spnNotFound() {
23+
#expect(MSSQLKerberosClassifier.classify("Server not found in Kerberos database") == .spnNotFound)
24+
}
25+
26+
@Test("Clock skew → clockSkew")
27+
func clockSkew() {
28+
#expect(MSSQLKerberosClassifier.classify("Clock skew too great") == .clockSkew)
29+
}
30+
31+
@Test("Cannot find KDC → realmNotResolved")
32+
func realmNotResolved() {
33+
#expect(MSSQLKerberosClassifier.classify("Cannot find KDC for realm CONTOSO.COM") == .realmNotResolved)
34+
}
35+
36+
@Test("Ticket expired → ticketExpired")
37+
func ticketExpired() {
38+
#expect(MSSQLKerberosClassifier.classify("Ticket expired") == .ticketExpired)
39+
}
40+
41+
@Test("An unrelated error is not classified as Kerberos")
42+
func unrelatedReturnsNil() {
43+
#expect(MSSQLKerberosClassifier.classify("certificate verify failed") == nil)
44+
#expect(MSSQLKerberosClassifier.classify("Login failed for user 'sa'") == nil)
45+
}
46+
}

Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable {
172172
if let kind = MSSQLTLSClassifier.classifySSLError(detail) {
173173
throw MSSQLCoreError.tlsHandshakeFailed(kind: kind, serverMessage: detail)
174174
}
175+
if options.authMethod == .windows, let kind = MSSQLKerberosClassifier.classify(detail) {
176+
throw MSSQLCoreError.kerberosAuthFailed(kind: kind, serverMessage: detail)
177+
}
175178
throw MSSQLCoreError.connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)")
176179
}
177180

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import Darwin
2+
import Foundation
3+
4+
final class AsyncLock: @unchecked Sendable {
5+
private let stateLock = NSLock()
6+
private var isLocked = false
7+
private var waiters: [CheckedContinuation<Void, Never>] = []
8+
9+
func acquire() async {
10+
if tryAcquireImmediately() { return }
11+
await withCheckedContinuation { continuation in
12+
enqueueOrResume(continuation)
13+
}
14+
}
15+
16+
func release() {
17+
stateLock.lock()
18+
if waiters.isEmpty {
19+
isLocked = false
20+
stateLock.unlock()
21+
} else {
22+
let next = waiters.removeFirst()
23+
stateLock.unlock()
24+
next.resume()
25+
}
26+
}
27+
28+
private func tryAcquireImmediately() -> Bool {
29+
stateLock.lock()
30+
defer { stateLock.unlock() }
31+
if !isLocked {
32+
isLocked = true
33+
return true
34+
}
35+
return false
36+
}
37+
38+
private func enqueueOrResume(_ continuation: CheckedContinuation<Void, Never>) {
39+
stateLock.lock()
40+
if !isLocked {
41+
isLocked = true
42+
stateLock.unlock()
43+
continuation.resume()
44+
} else {
45+
waiters.append(continuation)
46+
stateLock.unlock()
47+
}
48+
}
49+
}
50+
51+
final class MSSQLKerberosConnectGate: @unchecked Sendable {
52+
static let shared = MSSQLKerberosConnectGate()
53+
54+
private let lock = AsyncLock()
55+
56+
func connect(_ conn: FreeTDSConnection, principal: String, password: String) async throws {
57+
await lock.acquire()
58+
defer { lock.release() }
59+
60+
guard !principal.isEmpty, !password.isEmpty else {
61+
try await conn.connect()
62+
return
63+
}
64+
65+
let cache = try MSSQLKerberosCredentials.acquireTicket(principal: principal, password: password)
66+
defer { cache.destroy() }
67+
68+
let previous = getenv("KRB5CCNAME").map { String(cString: $0) }
69+
setenv("KRB5CCNAME", cache.name, 1)
70+
defer {
71+
if let previous {
72+
setenv("KRB5CCNAME", previous, 1)
73+
} else {
74+
unsetenv("KRB5CCNAME")
75+
}
76+
}
77+
78+
try await conn.connect()
79+
}
80+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import Foundation
2+
import GSS
3+
import TableProMSSQLCore
4+
5+
struct MSSQLKerberosCache {
6+
let name: String
7+
let filePath: String
8+
9+
func destroy() {
10+
try? FileManager.default.removeItem(atPath: filePath)
11+
}
12+
}
13+
14+
enum MSSQLKerberosCredentials {
15+
static func acquireTicket(principal: String, password: String) throws -> MSSQLKerberosCache {
16+
let fileName = "tablepro-krb5-\(UUID().uuidString)"
17+
let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName)
18+
let cacheName = "FILE:\(filePath)"
19+
20+
let importedName = try importName(principal)
21+
defer {
22+
var releaseMinor: OM_uint32 = 0
23+
var releasable: gss_name_t? = importedName
24+
_ = gss_release_name(&releaseMinor, &releasable)
25+
}
26+
27+
let attributes = NSMutableDictionary()
28+
attributes[kGSSICPassword] = password
29+
attributes[kGSSICKerberosCacheName] = cacheName
30+
31+
var cred: gss_cred_id_t?
32+
var errorRef: Unmanaged<CFError>?
33+
let status = gss_aapl_initial_cred(
34+
importedName,
35+
&__gss_krb5_mechanism_oid_desc,
36+
attributes as CFDictionary,
37+
&cred,
38+
&errorRef
39+
)
40+
41+
guard status == 0 else {
42+
let message = errorRef?.takeRetainedValue().localizedDescription
43+
?? String(localized: "Kerberos ticket request failed")
44+
try? FileManager.default.removeItem(atPath: filePath)
45+
throw MSSQLCoreError.kerberosAuthFailed(
46+
kind: MSSQLKerberosClassifier.classify(message) ?? .wrongPassword,
47+
serverMessage: message
48+
)
49+
}
50+
51+
if cred != nil {
52+
var releaseMinor: OM_uint32 = 0
53+
_ = gss_release_cred(&releaseMinor, &cred)
54+
}
55+
56+
return MSSQLKerberosCache(name: cacheName, filePath: filePath)
57+
}
58+
59+
private static func importName(_ principal: String) throws -> gss_name_t {
60+
var minor: OM_uint32 = 0
61+
var name: gss_name_t?
62+
let status = principal.withCString { cString -> OM_uint32 in
63+
var buffer = gss_buffer_desc(
64+
length: strlen(cString),
65+
value: UnsafeMutableRawPointer(mutating: cString)
66+
)
67+
return gss_import_name(&minor, &buffer, &__gss_c_nt_user_name_oid_desc, &name)
68+
}
69+
guard status == 0, let name else {
70+
throw MSSQLCoreError.kerberosAuthFailed(
71+
kind: .principalUnknown,
72+
serverMessage: String(
73+
format: String(localized: "Invalid Kerberos principal: %@"),
74+
principal
75+
)
76+
)
77+
}
78+
return name
79+
}
80+
}

0 commit comments

Comments
 (0)