Skip to content

Add keychain auth provider #3768

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

Merged
merged 15 commits into from
Oct 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 268 additions & 4 deletions Sources/Basics/AuthorizationProvider.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2021 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See http://swift.org/LICENSE.txt for license information
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

import Foundation
import struct Foundation.Data
import struct Foundation.URL
#if canImport(Security)
import Security
#endif

import TSCBasic
import TSCUtility

public protocol AuthorizationProvider {
func authentication(for url: URL) -> (user: String, password: String)?
func authentication(for url: Foundation.URL) -> (user: String, password: String)?
}

extension AuthorizationProvider {
public func httpAuthorizationHeader(for url: URL) -> String? {
public enum AuthorizationProviderError: Error {
case invalidURLHost
case notFound
case cannotEncodePassword
case other(String)
}

public extension AuthorizationProvider {
func httpAuthorizationHeader(for url: Foundation.URL) -> String? {
guard let (user, password) = self.authentication(for: url) else {
return nil
}
Expand All @@ -24,3 +40,251 @@ extension AuthorizationProvider {
return "Basic \(authData.base64EncodedString())"
}
}

extension Foundation.URL {
var authenticationID: String? {
guard let host = host?.lowercased() else {
return nil
}
return host.isEmpty ? nil : host
}
}

// MARK: - netrc

public struct NetrcAuthorizationProvider: AuthorizationProvider {
let path: AbsolutePath
private let fileSystem: FileSystem

private var underlying: TSCUtility.Netrc?

var machines: [TSCUtility.Netrc.Machine] {
self.underlying?.machines ?? []
}

public init(path: AbsolutePath, fileSystem: FileSystem) throws {
self.path = path
self.fileSystem = fileSystem
self.underlying = try Self.load(from: path)
}

public mutating func addOrUpdate(for url: Foundation.URL, user: String, password: String, callback: @escaping (Result<Void, Error>) -> Void) {
guard let machine = url.authenticationID else {
return callback(.failure(AuthorizationProviderError.invalidURLHost))
}

// Same entry already exists, no need to add or update
guard self.machines.first(where: { $0.name.lowercased() == machine && $0.login == user && $0.password == password }) == nil else {
return
}

do {
// Append to end of file
try self.fileSystem.withLock(on: self.path, type: .exclusive) {
let contents = try? self.fileSystem.readFileContents(self.path).contents
try self.fileSystem.writeFileContents(self.path) { stream in
// File does not exist yet
if let contents = contents {
stream.write(contents)
stream.write("\n")
}
stream.write("machine \(machine) login \(user) password \(password)")
stream.write("\n")
}
}

// At this point the netrc file should exist and non-empty
guard let netrc = try Self.load(from: self.path) else {
throw AuthorizationProviderError.other("Failed to update netrc file at \(self.path)")
}
self.underlying = netrc

callback(.success(()))
} catch {
callback(.failure(AuthorizationProviderError.other("Failed to update netrc file at \(self.path): \(error)")))
}
}

public func authentication(for url: Foundation.URL) -> (user: String, password: String)? {
self.machine(for: url).map { (user: $0.login, password: $0.password) }
}

private func machine(for url: Foundation.URL) -> TSCUtility.Netrc.Machine? {
if let machine = url.authenticationID, let existing = self.machines.first(where: { $0.name.lowercased() == machine }) {
return existing
}
if let existing = self.machines.first(where: { $0.isDefault }) {
return existing
}
return .none
}

private static func load(from path: AbsolutePath) throws -> TSCUtility.Netrc? {
do {
return try TSCUtility.Netrc.load(fromFileAtPath: path).get()
} catch {
switch error {
case Netrc.Error.fileNotFound, Netrc.Error.machineNotFound:
// These are recoverable errors. We will just create the file and append entry to it.
return nil
default:
throw error
}
}
}
}

// MARK: - Keychain

#if canImport(Security)
public struct KeychainAuthorizationProvider: AuthorizationProvider {
private let observabilityScope: ObservabilityScope

public init(observabilityScope: ObservabilityScope) {
self.observabilityScope = observabilityScope
}

public func addOrUpdate(for url: Foundation.URL, user: String, password: String, callback: @escaping (Result<Void, Error>) -> Void) {
guard let server = url.authenticationID else {
return callback(.failure(AuthorizationProviderError.invalidURLHost))
}
guard let passwordData = password.data(using: .utf8) else {
return callback(.failure(AuthorizationProviderError.cannotEncodePassword))
}
let `protocol` = self.protocol(for: url)

do {
if !(try self.update(server: server, protocol: `protocol`, account: user, password: passwordData)) {
try self.create(server: server, protocol: `protocol`, account: user, password: passwordData)
}
callback(.success(()))
} catch {
callback(.failure(error))
}
}

public func authentication(for url: Foundation.URL) -> (user: String, password: String)? {
guard let server = url.authenticationID else {
return nil
}

do {
guard let existingItem = try self.search(server: server, protocol: self.protocol(for: url)) as? [String: Any],
let passwordData = existingItem[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: String.Encoding.utf8),
let account = existingItem[kSecAttrAccount as String] as? String
else {
throw AuthorizationProviderError.other("Failed to extract credentials for server \(server) from keychain")
}
return (user: account, password: password)
} catch {
switch error {
case AuthorizationProviderError.notFound:
self.observabilityScope.emit(info: "No credentials found for server \(server) in keychain")
case AuthorizationProviderError.other(let detail):
self.observabilityScope.emit(error: detail)
default:
self.observabilityScope.emit(error: "Failed to find credentials for server \(server) in keychain: \(error)")
}
return nil
}
}

private func create(server: String, protocol: CFString, account: String, password: Data) throws {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server,
kSecAttrProtocol as String: `protocol`,
kSecAttrAccount as String: account,
kSecValueData as String: password]

let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw AuthorizationProviderError.other("Failed to save credentials for server \(server) to keychain: status \(status)")
}
}

private func update(server: String, protocol: CFString, account: String, password: Data) throws -> Bool {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server,
kSecAttrProtocol as String: `protocol`]
let attributes: [String: Any] = [kSecAttrAccount as String: account,
kSecValueData as String: password]

let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status != errSecItemNotFound else {
return false
}
guard status == errSecSuccess else {
throw AuthorizationProviderError.other("Failed to update credentials for server \(server) in keychain: status \(status)")
}
return true
}

private func search(server: String, protocol: CFString) throws -> CFTypeRef? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server,
kSecAttrProtocol as String: `protocol`,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true]

var item: CFTypeRef?
// Search keychain for server's username and password, if any.
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else {
throw AuthorizationProviderError.notFound
}
guard status == errSecSuccess else {
throw AuthorizationProviderError.other("Failed to find credentials for server \(server) in keychain: status \(status)")
}

return item
}

private func `protocol`(for url: Foundation.URL) -> CFString {
// See https://developer.apple.com/documentation/security/keychain_services/keychain_items/item_attribute_keys_and_values?language=swift
// for a list of possible values for the `kSecAttrProtocol` attribute.
switch url.scheme?.lowercased() {
case "https":
return kSecAttrProtocolHTTPS
default:
return kSecAttrProtocolHTTPS
}
}
}
#endif

// MARK: - Composite

public struct CompositeAuthorizationProvider: AuthorizationProvider {
private let providers: [AuthorizationProvider]
private let observabilityScope: ObservabilityScope

public init(_ providers: AuthorizationProvider..., observabilityScope: ObservabilityScope) {
self.init(providers, observabilityScope: observabilityScope)
}

public init(_ providers: [AuthorizationProvider], observabilityScope: ObservabilityScope) {
self.providers = providers
self.observabilityScope = observabilityScope
}

public func authentication(for url: Foundation.URL) -> (user: String, password: String)? {
for provider in self.providers {
if let authentication = provider.authentication(for: url) {
switch provider {
case let provider as NetrcAuthorizationProvider:
self.observabilityScope.emit(info: "Credentials for \(url) found in netrc file at \(provider.path)")
#if canImport(Security)
case is KeychainAuthorizationProvider:
self.observabilityScope.emit(info: "Credentials for \(url) found in keychain")
#endif
default:
self.observabilityScope.emit(info: "Credentials for \(url) found in \(provider)")
}
return authentication
}
}
return nil
}
}
2 changes: 2 additions & 0 deletions Sources/Basics/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ target_link_libraries(Basics PRIVATE
# NOTE(compnerd) workaround for CMake not setting up include flags yet
set_target_properties(Basics PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY})
target_link_options(Basics PRIVATE
"$<$<PLATFORM_ID:Darwin>:SHELL:-Xlinker -framework -Xlinker Security>")

if(USE_CMAKE_INSTALL)
install(TARGETS Basics
Expand Down
14 changes: 12 additions & 2 deletions Sources/Commands/SwiftTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -496,8 +496,18 @@ public class SwiftTool {
}

func getAuthorizationProvider() throws -> AuthorizationProvider? {
// currently only single provider: netrc
return try self.getNetrcConfig()?.get()
var providers = [AuthorizationProvider]()
// netrc file has higher specificity than keychain so use it first
if let workspaceNetrc = try self.getNetrcConfig()?.get() {
providers.append(workspaceNetrc)
}

// TODO: add --no-keychain option to allow opt-out
//#if canImport(Security)
// providers.append(KeychainAuthorizationProvider(observabilityScope: self.observabilityScope))
//#endif

return providers.isEmpty ? nil : CompositeAuthorizationProvider(providers, observabilityScope: self.observabilityScope)
}

func getNetrcConfig() -> Workspace.Configuration.Netrc? {
Expand Down
27 changes: 2 additions & 25 deletions Sources/Workspace/WorkspaceConfiguration.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

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

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

private static func load(_ path: AbsolutePath, fileSystem: FileSystem) throws -> AuthorizationProvider {
Copy link
Contributor

@tomerd tomerd Oct 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workspace.Configuration.Netrc is designed to only concern itself with NetrcAuthorizationProvider, so most of the code that was removed (moved to Basics really) here should be put back, and then in SwiftTool::getAuthorizationProvider we would create the CompositeAuthorizationProvider from the one returned from self.getNetrcConfig + the keychain one, i.e.

func getAuthorizationProvider() throws -> AuthorizationProvider? {
        // currently only single provider: netrc
        return try self.getNetrcConfig()?.get()
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I undid the change in load, but NetrcAuthorizationProvider can be generic?

Quoting SE-0292:

If the user passes the --login and --password options to the set subcommand along with the --global option, the user-level .netrc file is updated instead. When Swift Package Manager connects to a custom registry, it first consults the project's .netrc file, if one exists. If no entry is found for the custom registry, Swift Package Manager then consults the user-level .netrc file, if one exists.

So potentially in SwiftTool::getAuthorizationProvider we'd need to add another NetrcAuthorizationProvider that points to the user-level netrc.

Copy link
Contributor Author

@yim-lee yim-lee Oct 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended 6cc7ece

Copy link
Contributor

@tomerd tomerd Oct 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. making NetrcAuthorizationProvider generic makes sense

  2. I think the local vs. user netrc file logic could go into

func getNetrcConfig() -> Workspace.Configuration.Netrc? {
        guard options.netrc else { return nil }

        if let configuredPath = options.netrcFilePath {
            guard localFileSystem.exists(configuredPath) else {
                self.observabilityScope.emit(error: "Did not find .netrc file at \(configuredPath).")
                return nil
            }

            return .init(path: configuredPath, fileSystem: localFileSystem)
        } else {
            let defaultPath = localFileSystem.homeDirectory.appending(component: ".netrc")
            guard localFileSystem.exists(defaultPath) else { return nil }

            return .init(path: defaultPath, fileSystem: localFileSystem)
        }
    }

which now loads the netrc by either a flag passed to the CLI or the user level, but this can be extended to also include the local case ?

Copy link
Contributor

@tomerd tomerd Oct 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think that with this change Workspace.Configuration.Netrc becomes redundant? any reason why not simply remove it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason why not simply remove it?

My original intent with this PR is to add keychain auth capability. I didn't plan to change existing behavior or cover new ones.

Copy link
Contributor Author

@yim-lee yim-lee Oct 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think that with this change Workspace.Configuration.Netrc becomes redundant?

Workspace.Configuration.Netrc might be used elsewhere through libSwiftPM? It doesn't feel right to me that only SwiftTool needs this logic.

I think the local vs. user netrc file logic could go into

That code is for SwiftTool only and I assume SwiftPackageRegistryTool will have to create its own AuthorizationProvider in a similar way. I don't know if the logic for both tools is supposed to be the same, but my current understanding is that they are not.

AFAICT options.netrcFilePath isn't necessarily a path within the workspace, so SwiftTool uses either a custom netrc file location, which can be anywhere, or tries to find one in user's home directory. Regardless, it uses only one netrc file.

SwiftPackageRegistryTool IIUC, looks for netrc file in both the workspace (same as project?) and user's home directory. i.e., it might use two netrc files.

If the tools were to share the same logic, then the code should be moved outside of SwiftTool (to where?), and IMO this kind of logic doesn't belong in the tool/"UI" level.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwiftTool is a shared impl for all the CLI tools, so code there would be shared across them, but you are right that this is not needed to be dealt with in this PR - we can follow up with consolidating the logic as we make more progress

let netrc = try TSCUtility.Netrc.load(fromFileAtPath: path).get()
return NetrcAuthorizationProvider(netrc)
}

struct NetrcAuthorizationProvider: AuthorizationProvider {
private let underlying: TSCUtility.Netrc

init(_ underlying: TSCUtility.Netrc) {
self.underlying = underlying
}

func authentication(for url: Foundation.URL) -> (user: String, password: String)? {
return self.machine(for: url).map { (user: $0.login, password: $0.password) }
}

private func machine(for url: Foundation.URL) -> TSCUtility.Netrc.Machine? {
if let machine = self.underlying.machines.first(where: { $0.name.lowercased() == url.host?.lowercased() }) {
return machine
}
if let machine = self.underlying.machines.first(where: { $0.isDefault }) {
return machine
}
return .none
}
try NetrcAuthorizationProvider(path: path, fileSystem: fileSystem)
}
}
}
Expand Down
Loading