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
1120public 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+ }
0 commit comments