-
-
Notifications
You must be signed in to change notification settings - Fork 337
feat(mssql): auto-derive Kerberos realm for cross-realm Windows Authentication #1949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
01b0027
a3e22fc
b39bff6
2d113f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import Foundation | ||
|
|
||
| /// Decides whether a realm resolved from the system Kerberos configuration is worth overriding | ||
| /// FreeTDS's default service principal with. | ||
| /// | ||
| /// macOS Heimdal never consults `default_realm` when it maps a host to a realm. With no matching | ||
| /// `[domain_realm]` entry it synthesizes the DNS domain after the first label, upper-cased, so a | ||
| /// correct `default_realm` would be replaced by a guess derived from DNS. That guess breaks the | ||
| /// setups where the realm and the DNS domain differ (disjoint namespaces, CNAME aliases, hosts | ||
| /// whose service principal lives in the forest root), which authenticate today. | ||
| /// | ||
| /// Only a realm that carries more than the host's own domain came from the Kerberos configuration, | ||
| /// and only that one is safe to send. This matches the JDBC driver, which maps a host through | ||
| /// `[domain_realm]` and otherwise falls back to `default_realm`. | ||
| public enum MSSQLKerberosRealm { | ||
| public static func domainGuess(forHost host: String) -> String? { | ||
| let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard let firstDot = trimmed.firstIndex(of: ".") else { return nil } | ||
| let domain = trimmed[trimmed.index(after: firstDot)...] | ||
| return domain.isEmpty ? nil : domain.uppercased() | ||
| } | ||
|
|
||
| public static func isConfigured(_ realm: String, forHost host: String) -> Bool { | ||
| let trimmedRealm = realm.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !trimmedRealm.isEmpty else { return false } | ||
| guard let domainGuess = domainGuess(forHost: host) else { return true } | ||
| return trimmedRealm.caseInsensitiveCompare(domainGuess) != .orderedSame | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import Foundation | ||
|
|
||
| /// Builds the SQL Server Kerberos service principal name (SPN) with an explicit realm. | ||
| /// | ||
| /// FreeTDS otherwise constructs an unrealmed `MSSQLSvc/host:port`, which macOS Heimdal resolves | ||
| /// against the client's `default_realm` with no cross-realm referral. Windows Authentication then | ||
| /// fails (`KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`) whenever the SQL Server's realm differs from the Mac's | ||
| /// default realm. Supplying the realm-qualified SPN via `DBSETSERVERPRINCIPAL` makes Heimdal request | ||
| /// the service ticket from the correct realm. | ||
| public enum MSSQLKerberosSPN { | ||
| /// Returns `MSSQLSvc/<host>:<port>@<REALM>`, or `nil` when no realm is set (letting FreeTDS keep | ||
| /// its default, unrealmed SPN). The realm is upper-cased to match Active Directory convention. | ||
| public static func build(host: String, port: Int, realm: String?) -> String? { | ||
| guard let realm = realm?.trimmingCharacters(in: .whitespacesAndNewlines), !realm.isEmpty else { | ||
| return nil | ||
| } | ||
| let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !trimmedHost.isEmpty else { return nil } | ||
| return "MSSQLSvc/\(trimmedHost):\(port)@\(realm.uppercased())" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import Testing | ||
| @testable import TableProMSSQLCore | ||
|
|
||
| @Suite("MSSQL Kerberos realm") | ||
| struct MSSQLKerberosRealmTests { | ||
| @Test("A realm that only repeats the host's DNS domain is Heimdal's guess, not configuration") | ||
| func domainDerivedRealmIsNotConfigured() { | ||
| #expect(!MSSQLKerberosRealm.isConfigured("CORP.CONTOSO.LOCAL", forHost: "sql.corp.contoso.local")) | ||
| #expect(!MSSQLKerberosRealm.isConfigured("EXAMPLE.COM", forHost: "sql.example.com")) | ||
| } | ||
|
|
||
| @Test("A realm that differs from the host's DNS domain came from the Kerberos configuration") | ||
| func mappedRealmIsConfigured() { | ||
| #expect(MSSQLKerberosRealm.isConfigured("RESOURCE.REALM.COM", forHost: "sql.example.com")) | ||
| #expect(MSSQLKerberosRealm.isConfigured("AD.CONTOSO.COM", forHost: "sql.corp.contoso.local")) | ||
| } | ||
|
|
||
| @Test("Realm comparison ignores case, so a lower-cased mapping is still treated as the guess") | ||
| func comparisonIgnoresCase() { | ||
| #expect(!MSSQLKerberosRealm.isConfigured("example.com", forHost: "sql.example.com")) | ||
| #expect(!MSSQLKerberosRealm.isConfigured(" Example.Com ", forHost: "sql.example.com")) | ||
| } | ||
|
|
||
| @Test("An empty realm is never adopted") | ||
| func emptyRealmIsNotConfigured() { | ||
| #expect(!MSSQLKerberosRealm.isConfigured("", forHost: "sql.example.com")) | ||
| #expect(!MSSQLKerberosRealm.isConfigured(" ", forHost: "sql.example.com")) | ||
| } | ||
|
|
||
| @Test("A dot-less host has no domain to guess from, so any realm must be configured") | ||
| func shortHostAlwaysConfigured() { | ||
| #expect(MSSQLKerberosRealm.isConfigured("AD.CONTOSO.COM", forHost: "sqlhost")) | ||
| #expect(MSSQLKerberosRealm.domainGuess(forHost: "sqlhost") == nil) | ||
| } | ||
|
|
||
| @Test("The domain guess is every label after the first, upper-cased") | ||
| func domainGuessDropsOnlyTheFirstLabel() { | ||
| #expect(MSSQLKerberosRealm.domainGuess(forHost: "a.b.c.d.com") == "B.C.D.COM") | ||
| #expect(MSSQLKerberosRealm.domainGuess(forHost: "sql.example.com") == "EXAMPLE.COM") | ||
| #expect(MSSQLKerberosRealm.domainGuess(forHost: " sql.example.com ") == "EXAMPLE.COM") | ||
| #expect(MSSQLKerberosRealm.domainGuess(forHost: "sql.") == nil) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import Testing | ||
| @testable import TableProMSSQLCore | ||
|
|
||
| @Suite("MSSQL Kerberos SPN") | ||
| struct MSSQLKerberosSPNTests { | ||
| @Test("Realm-qualified SPN is built and the realm is upper-cased") | ||
| func buildsRealmQualifiedSPN() { | ||
| #expect( | ||
| MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: "resource.realm.com") | ||
| == "MSSQLSvc/sql.example.com:1433@RESOURCE.REALM.COM" | ||
| ) | ||
| } | ||
|
|
||
| @Test("No realm yields no SPN, so FreeTDS keeps its default") | ||
| func nilRealmReturnsNil() { | ||
| #expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: nil) == nil) | ||
| } | ||
|
|
||
| @Test("Empty or whitespace-only realm yields no SPN") | ||
| func emptyRealmReturnsNil() { | ||
| #expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: "") == nil) | ||
| #expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: " ") == nil) | ||
| } | ||
|
|
||
| @Test("Whitespace is trimmed from host and realm") | ||
| func trimsWhitespace() { | ||
| #expect( | ||
| MSSQLKerberosSPN.build(host: " sql.example.com ", port: 1433, realm: " resource.realm.com ") | ||
| == "MSSQLSvc/sql.example.com:1433@RESOURCE.REALM.COM" | ||
| ) | ||
| } | ||
|
|
||
| @Test("A non-default port is included in the SPN") | ||
| func includesNonDefaultPort() { | ||
| #expect( | ||
| MSSQLKerberosSPN.build(host: "sql.example.com", port: 14330, realm: "R.COM") | ||
| == "MSSQLSvc/sql.example.com:14330@R.COM" | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import Foundation | ||
| import GSS | ||
| import TableProMSSQLCore | ||
|
|
||
| /// Resolves the canonical SQL Server Kerberos service (host + realm) for a connection host. | ||
| /// | ||
| /// macOS Heimdal does not apply the system Kerberos configuration (`[domain_realm]`) when FreeTDS | ||
| /// builds its own SPN string, so a cross-realm host fails with `KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`. | ||
| /// We resolve the realm here (like the JDBC driver) and hand FreeTDS an explicit SPN via | ||
| /// `DBSETSERVERPRINCIPAL`. | ||
| /// | ||
| /// Once an explicit SPN is set, FreeTDS stops canonicalizing a short hostname to its FQDN (which it | ||
| /// otherwise does with `getaddrinfo` for dot-less names). To avoid regressing those connections we | ||
| /// perform the same canonicalization here, so the SPN carries the FQDN the service is registered | ||
| /// under, short name or CNAME included. | ||
| enum MSSQLKerberosRealmResolver { | ||
| /// Canonical host and realm for `host`, or `nil` when the realm is unresolved or Heimdal only | ||
| /// guessed it from the host's own DNS domain. The caller then leaves the SPN to FreeTDS, whose | ||
| /// unrealmed principal resolves against `default_realm`. | ||
| static func canonicalService(forHost host: String) -> (host: String, realm: String)? { | ||
| let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !trimmed.isEmpty else { return nil } | ||
| let canonicalHost = canonicalHostname(trimmed) | ||
| guard let realm = realm(forHost: canonicalHost), | ||
| MSSQLKerberosRealm.isConfigured(realm, forHost: canonicalHost) | ||
| else { return nil } | ||
| return (host: canonicalHost, realm: realm) | ||
| } | ||
|
|
||
| /// Mirrors FreeTDS: canonicalize a dot-less (short) hostname to its FQDN via `getaddrinfo`. | ||
| /// A name that already contains a dot is used as-is, matching FreeTDS's own behavior. | ||
| private static func canonicalHostname(_ host: String) -> String { | ||
| guard !host.contains(".") else { return host } | ||
| var hints = addrinfo() | ||
| hints.ai_flags = AI_CANONNAME | ||
| hints.ai_family = AF_UNSPEC | ||
| hints.ai_socktype = SOCK_STREAM | ||
| var result: UnsafeMutablePointer<addrinfo>? | ||
| guard getaddrinfo(host, nil, &hints, &result) == 0, let result else { return host } | ||
| defer { freeaddrinfo(result) } | ||
| if let canonical = result.pointee.ai_canonname { | ||
| let name = String(cString: canonical) | ||
| if name.contains(".") { return name } | ||
| } | ||
| return host | ||
| } | ||
|
|
||
| /// Realm for a host from the system Kerberos configuration (`[domain_realm]`, `default_realm`), | ||
| /// via GSS name canonicalization, the same resolution the KDC clients use. | ||
| private static func realm(forHost host: String) -> String? { | ||
| var minor: OM_uint32 = 0 | ||
| guard let cString = strdup("MSSQLSvc@\(host)") else { return nil } | ||
| var nameBuffer = gss_buffer_desc(length: strlen(cString), value: cString) | ||
| var importedName: gss_name_t? | ||
| let importStatus = gss_import_name( | ||
| &minor, &nameBuffer, &__gss_c_nt_hostbased_service_oid_desc, &importedName | ||
| ) | ||
| free(cString) | ||
| guard importStatus == GSS_S_COMPLETE, let importedName else { return nil } | ||
| defer { | ||
| var releaseMinor: OM_uint32 = 0 | ||
| var releasable: gss_name_t? = importedName | ||
| _ = gss_release_name(&releaseMinor, &releasable) | ||
| } | ||
|
|
||
| var canonicalName: gss_name_t? | ||
| let canonStatus = gss_canonicalize_name( | ||
| &minor, importedName, &__gss_krb5_mechanism_oid_desc, &canonicalName | ||
| ) | ||
| guard canonStatus == GSS_S_COMPLETE, let canonicalName else { return nil } | ||
| defer { | ||
| var releaseMinor: OM_uint32 = 0 | ||
| var releasable: gss_name_t? = canonicalName | ||
| _ = gss_release_name(&releaseMinor, &releasable) | ||
| } | ||
|
|
||
| var displayBuffer = gss_buffer_desc() | ||
| var nameType: gss_OID? | ||
| let displayStatus = gss_display_name(&minor, canonicalName, &displayBuffer, &nameType) | ||
| defer { | ||
| var releaseMinor: OM_uint32 = 0 | ||
| _ = gss_release_buffer(&releaseMinor, &displayBuffer) | ||
| } | ||
| guard displayStatus == GSS_S_COMPLETE, let value = displayBuffer.value else { return nil } | ||
|
|
||
| let display = String(data: Data(bytes: value, count: displayBuffer.length), encoding: .utf8) ?? "" | ||
| guard let atIndex = display.lastIndex(of: "@") else { return nil } | ||
| let realm = String(display[display.index(after: atIndex)...]) | ||
| return realm.isEmpty ? nil : realm | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the user enters a short hostname or CNAME, GSS canonicalization can produce the FQDN that has the SQL Server SPN registered, but this resolver discards that canonical host and returns only the realm; the later Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| // | ||
| // ConnectionField+AuthFieldOrder.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import TableProPluginKit | ||
|
|
||
| extension Collection where Element == ConnectionField { | ||
| /// Fields that decide whether the built-in Username and Password appear: either they carry the | ||
| /// flag themselves (an auth-method dropdown, a password-file toggle), or they gate a dependent | ||
| /// field that carries it (SQL Server's Kerberos principal, Snowflake's OAuth token). | ||
| var credentialControllerIds: Set<String> { | ||
| Set( | ||
| filter { $0.hidesUsername || $0.hidesPassword } | ||
| .map { $0.visibleWhen?.fieldId ?? $0.id } | ||
| ) | ||
| } | ||
|
|
||
| /// Splits the fields so the credential controllers render above the built-in Username and | ||
| /// Password. A controller placed below them shifts position every time its own selection shows | ||
| /// or hides those credentials. | ||
| func splitCredentialControllers() -> (controllers: [ConnectionField], rest: [ConnectionField]) { | ||
| let controllerIds = credentialControllerIds | ||
| var controllers: [ConnectionField] = [] | ||
| var rest: [ConnectionField] = [] | ||
| for field in self { | ||
| if controllerIds.contains(field.id) { | ||
| controllers.append(field) | ||
| } else { | ||
| rest.append(field) | ||
| } | ||
| } | ||
| return (controllers, rest) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a cross-realm server's DNS suffix and Kerberos realm are the same (for example, host
sql.resource.example.com,[domain_realm] .resource.example.com = RESOURCE.EXAMPLE.COM, and a different clientdefault_realm), GSS returns exactly the valuedomainGuessproduces. This predicate treats that explicitly configured mapping as a Heimdal guess and returnsnil, so FreeTDS receives no realm-qualified SPN and falls back to the client's default realm—the cross-realm connection this change is meant to support still fails. The resolver needs to distinguish an actual[domain_realm]mapping from a derived suffix rather than discarding equal values.Useful? React with 👍 / 👎.