Skip to content

Commit 3874cdc

Browse files
authored
Add keychain auth provider (#3768)
Motivation: Support keychain auth on macOS. Modifications: - Implement `KeychainAuthorizationProvider` and `CompositeAuthorizationProvider` - Move `NetrcAuthorizationProvider` to `Basics` and update conformance - Add tests rdar://83682028
1 parent 89c24f6 commit 3874cdc

File tree

6 files changed

+401
-47
lines changed

6 files changed

+401
-47
lines changed
Lines changed: 268 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,35 @@
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 canImport(Security)
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+
func authentication(for url: Foundation.URL) -> (user: String, password: String)?
1322
}
1423

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

Sources/Basics/CMakeLists.txt

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

3638
if(USE_CMAKE_INSTALL)
3739
install(TARGETS Basics

Sources/Commands/SwiftTool.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,8 +496,18 @@ public class SwiftTool {
496496
}
497497

498498
func getAuthorizationProvider() throws -> AuthorizationProvider? {
499-
// currently only single provider: netrc
500-
return try self.getNetrcConfig()?.get()
499+
var providers = [AuthorizationProvider]()
500+
// netrc file has higher specificity than keychain so use it first
501+
if let workspaceNetrc = try self.getNetrcConfig()?.get() {
502+
providers.append(workspaceNetrc)
503+
}
504+
505+
// TODO: add --no-keychain option to allow opt-out
506+
//#if canImport(Security)
507+
// providers.append(KeychainAuthorizationProvider(observabilityScope: self.observabilityScope))
508+
//#endif
509+
510+
return providers.isEmpty ? nil : CompositeAuthorizationProvider(providers, observabilityScope: self.observabilityScope)
501511
}
502512

503513
func getNetrcConfig() -> Workspace.Configuration.Netrc? {

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+
try NetrcAuthorizationProvider(path: path, fileSystem: fileSystem)
377354
}
378355
}
379356
}

0 commit comments

Comments
 (0)