Skip to content

Commit a30f7af

Browse files
committed
Add keychain auth provider
Motivation: Support keychain auth on macOS. Modifications: - Add `addOrUpdate` API to `AuthorizationProvider` protocol - Implement `KeychainAuthorizationProvider` - Move `NetrcAuthorizationProvider` to `Basics` and update conformance - Add tests rdar://83682028
1 parent 3d48f94 commit a30f7af

File tree

4 files changed

+301
-32
lines changed

4 files changed

+301
-32
lines changed
Lines changed: 242 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,37 @@
11
/*
22
This source file is part of the Swift.org open source project
3+
34
Copyright (c) 2021 Apple Inc. and the Swift project authors
45
Licensed under Apache License v2.0 with Runtime Library Exception
6+
57
See http://swift.org/LICENSE.txt for license information
68
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
79
*/
810

9-
import Foundation
11+
import struct Foundation.Data
12+
import struct Foundation.URL
13+
#if os(macOS)
14+
import Security
15+
#endif
16+
17+
import TSCBasic
18+
import TSCUtility
1019

1120
public protocol AuthorizationProvider {
12-
func authentication(for url: URL) -> (user: String, password: String)?
21+
mutating func addOrUpdate(for url: Foundation.URL, user: String, password: String, callback: @escaping (Result<Void, Error>) -> Void)
22+
23+
func authentication(for url: Foundation.URL) -> (user: String, password: String)?
24+
}
25+
26+
public enum AuthorizationProviderError: Error {
27+
case noURLHost
28+
case notFound
29+
case unexpectedPasswordData
30+
case unexpectedError(String)
1331
}
1432

1533
extension AuthorizationProvider {
16-
public func httpAuthorizationHeader(for url: URL) -> String? {
34+
public func httpAuthorizationHeader(for url: Foundation.URL) -> String? {
1735
guard let (user, password) = self.authentication(for: url) else {
1836
return nil
1937
}
@@ -24,3 +42,224 @@ extension AuthorizationProvider {
2442
return "Basic \(authData.base64EncodedString())"
2543
}
2644
}
45+
46+
// MARK: - netrc
47+
48+
public struct NetrcAuthorizationProvider: AuthorizationProvider {
49+
private let path: AbsolutePath
50+
private let fileSystem: FileSystem
51+
52+
private var underlying: TSCUtility.Netrc?
53+
54+
private var machines: [TSCUtility.Netrc.Machine] {
55+
self.underlying?.machines ?? []
56+
}
57+
58+
public init(path: AbsolutePath, fileSystem: FileSystem) throws {
59+
self.path = path
60+
self.fileSystem = fileSystem
61+
self.underlying = try Self.loadFromDisk(path: path)
62+
}
63+
64+
public mutating func addOrUpdate(for url: Foundation.URL, user: String, password: String, callback: @escaping (Result<Void, Error>) -> Void) {
65+
guard let machineName = self.machineName(for: url) else {
66+
return callback(.failure(AuthorizationProviderError.noURLHost))
67+
}
68+
let machine = TSCUtility.Netrc.Machine(name: machineName, login: user, password: password)
69+
70+
var machines = [TSCUtility.Netrc.Machine]()
71+
var hasExisting = false
72+
73+
self.machines.forEach {
74+
if $0.name.lowercased() != machineName {
75+
machines.append($0)
76+
} else if !hasExisting {
77+
// Update existing entry and retain one copy only
78+
machines.append(machine)
79+
hasExisting = true
80+
}
81+
}
82+
83+
// New entry
84+
if !hasExisting {
85+
machines.append(machine)
86+
}
87+
88+
do {
89+
try self.saveToDisk(machines: machines)
90+
// At this point the netrc file should exist and non-empty
91+
guard let netrc = try Self.loadFromDisk(path: self.path) else {
92+
throw AuthorizationProviderError.unexpectedError("Failed to update netrc file at \(self.path)")
93+
}
94+
self.underlying = netrc
95+
callback(.success(()))
96+
} catch {
97+
callback(.failure(AuthorizationProviderError.unexpectedError("Failed to update netrc file at \(self.path): \(error)")))
98+
}
99+
}
100+
101+
public func authentication(for url: Foundation.URL) -> (user: String, password: String)? {
102+
self.machine(for: url).map { (user: $0.login, password: $0.password) }
103+
}
104+
105+
private func machineName(for url: Foundation.URL) -> String? {
106+
url.host?.lowercased()
107+
}
108+
109+
private func machine(for url: Foundation.URL) -> TSCUtility.Netrc.Machine? {
110+
if let machineName = self.machineName(for: url), let machine = self.machines.first(where: { $0.name.lowercased() == machineName }) {
111+
return machine
112+
}
113+
if let machine = self.machines.first(where: { $0.isDefault }) {
114+
return machine
115+
}
116+
return .none
117+
}
118+
119+
private func saveToDisk(machines: [TSCUtility.Netrc.Machine]) throws {
120+
try self.fileSystem.withLock(on: self.path, type: .exclusive) {
121+
try self.fileSystem.writeFileContents(self.path) { stream in
122+
machines.forEach {
123+
stream.write("machine \($0.name) login \($0.login) password \($0.password)\n")
124+
}
125+
}
126+
}
127+
}
128+
129+
private static func loadFromDisk(path: AbsolutePath) throws -> TSCUtility.Netrc? {
130+
do {
131+
return try TSCUtility.Netrc.load(fromFileAtPath: path).get()
132+
} catch {
133+
switch error {
134+
case Netrc.Error.fileNotFound, Netrc.Error.machineNotFound:
135+
// These are recoverable errors. We will just create the file and append entry to it.
136+
return nil
137+
default:
138+
throw error
139+
}
140+
}
141+
}
142+
}
143+
144+
// MARK: - Keychain
145+
146+
#if os(macOS)
147+
public struct KeychainAuthorizationProvider: AuthorizationProvider {
148+
public init() {}
149+
150+
public func addOrUpdate(for url: Foundation.URL, user: String, password: String, callback: @escaping (Result<Void, Error>) -> Void) {
151+
guard let server = self.server(for: url) else {
152+
return callback(.failure(AuthorizationProviderError.noURLHost))
153+
}
154+
guard let passwordData = password.data(using: .utf8) else {
155+
return callback(.failure(AuthorizationProviderError.unexpectedPasswordData))
156+
}
157+
let `protocol` = self.`protocol`(for: url)
158+
159+
do {
160+
try self.update(server: server, protocol: `protocol`, account: user, password: passwordData)
161+
callback(.success(()))
162+
} catch AuthorizationProviderError.notFound {
163+
do {
164+
try self.create(server: server, protocol: `protocol`, account: user, password: passwordData)
165+
callback(.success(()))
166+
} catch {
167+
callback(.failure(error))
168+
}
169+
} catch {
170+
callback(.failure(error))
171+
}
172+
}
173+
174+
public func authentication(for url: Foundation.URL) -> (user: String, password: String)? {
175+
guard let server = self.server(for: url) else {
176+
return nil
177+
}
178+
179+
do {
180+
guard let existingItem = try self.search(server: server, protocol: self.`protocol`(for: url)) as? [String : Any],
181+
let passwordData = existingItem[kSecValueData as String] as? Data,
182+
let password = String(data: passwordData, encoding: String.Encoding.utf8),
183+
let account = existingItem[kSecAttrAccount as String] as? String else {
184+
throw AuthorizationProviderError.unexpectedError("Failed to extract credentials for server \(server) from keychain")
185+
}
186+
return (user: account, password: password)
187+
} catch {
188+
switch error {
189+
case AuthorizationProviderError.notFound:
190+
ObservabilitySystem.topScope.emit(info: "No credentials found for server \(server) in keychain")
191+
case AuthorizationProviderError.unexpectedError(let detail):
192+
ObservabilitySystem.topScope.emit(error: detail)
193+
default:
194+
ObservabilitySystem.topScope.emit(error: "Failed to find credentials for server \(server) in keychain: \(error)")
195+
}
196+
return nil
197+
}
198+
}
199+
200+
private func create(server: String, `protocol`: CFString, account: String, password: Data) throws {
201+
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
202+
kSecAttrServer as String: server,
203+
kSecAttrProtocol as String: `protocol`,
204+
kSecAttrAccount as String: account,
205+
kSecValueData as String: password]
206+
207+
let status = SecItemAdd(query as CFDictionary, nil)
208+
guard status == errSecSuccess else {
209+
throw AuthorizationProviderError.unexpectedError("Failed to save credentials for server \(server) to keychain: status \(status)")
210+
}
211+
}
212+
213+
private func update(server: String, `protocol`: CFString, account: String, password: Data) throws {
214+
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
215+
kSecAttrServer as String: server,
216+
kSecAttrProtocol as String: `protocol`]
217+
let attributes: [String: Any] = [kSecAttrAccount as String: account,
218+
kSecValueData as String: password]
219+
220+
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
221+
guard status != errSecItemNotFound else {
222+
throw AuthorizationProviderError.notFound
223+
}
224+
guard status == errSecSuccess else {
225+
throw AuthorizationProviderError.unexpectedError("Failed to update credentials for server \(server) in keychain: status \(status)")
226+
}
227+
}
228+
229+
private func search(server: String, `protocol`: CFString) throws -> CFTypeRef? {
230+
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
231+
kSecAttrServer as String: server,
232+
kSecAttrProtocol as String: `protocol`,
233+
kSecMatchLimit as String: kSecMatchLimitOne,
234+
kSecReturnAttributes as String: true,
235+
kSecReturnData as String: true]
236+
237+
var item: CFTypeRef?
238+
// Search keychain for server's username and password, if any.
239+
let status = SecItemCopyMatching(query as CFDictionary, &item)
240+
guard status != errSecItemNotFound else {
241+
throw AuthorizationProviderError.notFound
242+
}
243+
guard status == errSecSuccess else {
244+
throw AuthorizationProviderError.unexpectedError("Failed to find credentials for server \(server) in keychain: status \(status)")
245+
}
246+
247+
return item
248+
}
249+
250+
private func server(for url: Foundation.URL) -> String? {
251+
url.host?.lowercased()
252+
}
253+
254+
private func `protocol`(for url: Foundation.URL) -> CFString {
255+
// See https://developer.apple.com/documentation/security/keychain_services/keychain_items/item_attribute_keys_and_values?language=swift
256+
// for a list of possible values for the `kSecAttrProtocol` attribute.
257+
switch url.scheme?.lowercased() {
258+
case "https":
259+
return kSecAttrProtocolHTTPS
260+
default:
261+
return kSecAttrProtocolHTTPS
262+
}
263+
}
264+
}
265+
#endif

Sources/Basics/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ target_link_libraries(Basics PRIVATE
3131
# NOTE(compnerd) workaround for CMake not setting up include flags yet
3232
set_target_properties(Basics PROPERTIES
3333
INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY})
34+
target_link_options(Basics PRIVATE
35+
"$<$<PLATFORM_ID:Darwin>:SHELL:-Xlinker -framework -Xlinker Security>")
3436

3537
if(USE_CMAKE_INSTALL)
3638
install(TARGETS Basics

Sources/Workspace/WorkspaceConfiguration.swift

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2018 Apple Inc. and the Swift project authors
4+
Copyright (c) 2018-2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -350,30 +350,7 @@ extension Workspace.Configuration {
350350
}
351351

352352
private static func load(_ path: AbsolutePath, fileSystem: FileSystem) throws -> AuthorizationProvider {
353-
let netrc = try TSCUtility.Netrc.load(fromFileAtPath: path).get()
354-
return NetrcAuthorizationProvider(netrc)
355-
}
356-
357-
struct NetrcAuthorizationProvider: AuthorizationProvider {
358-
private let underlying: TSCUtility.Netrc
359-
360-
init(_ underlying: TSCUtility.Netrc) {
361-
self.underlying = underlying
362-
}
363-
364-
func authentication(for url: Foundation.URL) -> (user: String, password: String)? {
365-
return self.machine(for: url).map { (user: $0.login, password: $0.password) }
366-
}
367-
368-
private func machine(for url: Foundation.URL) -> TSCUtility.Netrc.Machine? {
369-
if let machine = self.underlying.machines.first(where: { $0.name.lowercased() == url.host?.lowercased() }) {
370-
return machine
371-
}
372-
if let machine = self.underlying.machines.first(where: { $0.isDefault }) {
373-
return machine
374-
}
375-
return .none
376-
}
353+
return try NetrcAuthorizationProvider(path: path, fileSystem: fileSystem)
377354
}
378355
}
379356
}

Tests/BasicsTests/AuthorizationProviderTests.swift

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,79 @@
88
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
99
*/
1010

11+
import XCTest
12+
1113
import Basics
1214
import TSCBasic
13-
import XCTest
15+
import TSCTestSupport
1416

1517
final class AuthorizationProviderTests: XCTestCase {
1618
func testBasicAPIs() {
1719
struct TestProvider: AuthorizationProvider {
18-
let map: [URL: (user: String, password: String)]
20+
private var map = [URL: (user: String, password: String)]()
21+
22+
mutating func addOrUpdate(for url: Foundation.URL, user: String, password: String, callback: @escaping (Result<Void, Error>) -> Void) {
23+
self.map[url] = (user, password)
24+
callback(.success(()))
25+
}
1926

2027
func authentication(for url: URL) -> (user: String, password: String)? {
2128
return self.map[url]
2229
}
2330
}
2431

25-
let url = URL(string: "http://\(UUID().uuidString)")!
32+
var provider = TestProvider()
33+
self.run(for: &provider)
34+
}
35+
36+
func testNetrc() throws {
37+
try testWithTemporaryDirectory { tmpPath in
38+
let netrcPath = tmpPath.appending(component: ".netrc")
39+
40+
var provider = try NetrcAuthorizationProvider(path: netrcPath, fileSystem: localFileSystem)
41+
self.run(for: &provider)
42+
}
43+
}
44+
45+
func testKeychain() throws {
46+
#if os(macOS) && ENABLE_KEYCHAIN_TEST
47+
#else
48+
try XCTSkipIf(true)
49+
#endif
50+
51+
var provider = KeychainAuthorizationProvider()
52+
self.run(for: &provider)
53+
}
54+
55+
private func run<Provider>(for provider: inout Provider) where Provider: AuthorizationProvider {
2656
let user = UUID().uuidString
57+
58+
let url = URL(string: "http://\(UUID().uuidString)")!
2759
let password = UUID().uuidString
28-
let provider = TestProvider(map: [url: (user: user, password: password)])
60+
61+
let otherURL = URL(string: "https://\(UUID().uuidString)")!
62+
let otherPassword = UUID().uuidString
63+
64+
// Add
65+
XCTAssertNoThrow(try tsc_await { callback in provider.addOrUpdate(for: url, user: user, password: password, callback: callback) })
66+
XCTAssertNoThrow(try tsc_await { callback in provider.addOrUpdate(for: otherURL, user: user, password: otherPassword, callback: callback) })
2967

3068
let auth = provider.authentication(for: url)
3169
XCTAssertEqual(auth?.user, user)
3270
XCTAssertEqual(auth?.password, password)
3371
XCTAssertEqual(provider.httpAuthorizationHeader(for: url), "Basic " + "\(user):\(password)".data(using: .utf8)!.base64EncodedString())
72+
73+
// Update
74+
let newPassword = UUID().uuidString
75+
XCTAssertNoThrow(try tsc_await { callback in provider.addOrUpdate(for: url, user: user, password: newPassword, callback: callback) })
76+
77+
let updatedAuth = provider.authentication(for: url)
78+
XCTAssertEqual(updatedAuth?.user, user)
79+
XCTAssertEqual(updatedAuth?.password, newPassword)
80+
XCTAssertEqual(provider.httpAuthorizationHeader(for: url), "Basic " + "\(user):\(newPassword)".data(using: .utf8)!.base64EncodedString())
81+
82+
let otherAuth = provider.authentication(for: otherURL)
83+
XCTAssertEqual(otherAuth?.user, user)
84+
XCTAssertEqual(otherAuth?.password, otherPassword)
3485
}
3586
}

0 commit comments

Comments
 (0)