From b8f7e3f5198e48f01ba43460374e1558d39743fa Mon Sep 17 00:00:00 2001 From: Kiel Gillard Date: Fri, 8 Nov 2019 09:51:59 +1100 Subject: [PATCH] Use ASWebAuthenticationSession to authenticate Feedly users and grant NNW access tokens. --- Frameworks/Account/Account.swift | 25 ++- .../Account/Feedly/FeedlyAPICaller.swift | 19 -- .../Feedly/FeedlyAccountDelegate+OAuth.swift | 4 - .../Feedly/FeedlyAccountDelegate.swift | 15 +- .../OAuthAuthorizationCodeGranting.swift | 4 +- .../Accounts/AccountsAddViewController.swift | 22 ++- .../AccountsFeedlyWebWindowController.swift | 4 +- NetNewsWire.xcodeproj/project.pbxproj | 97 +++++++-- .../OAuthAccountAuthorizationOperation.swift | 187 ++++++++++++++++++ ...OAuthAuthorizationClient+NetNewsWire.swift | 35 ++++ 10 files changed, 357 insertions(+), 55 deletions(-) create mode 100644 Shared/OAuthAccountAuthorizationOperation.swift create mode 100644 Shared/OAuthAuthorizationClient+NetNewsWire.swift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index f40d48c43..b3579391f 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -308,18 +308,23 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } } - public static func oauthAuthorizationClient(for type: AccountType) -> OAuthAuthorizationClient { - let grantingType: OAuthAuthorizationGranting.Type - switch type { - case .feedly: - grantingType = FeedlyAccountDelegate.self - default: - fatalError("\(type) does not support OAuth authorization code granting.") + public var oauthAuthorizationClient: OAuthAuthorizationClient? { + get { + guard let oauthGrantingAccount = delegate as? OAuthAuthorizationGranting else { + assertionFailure() + return nil + } + return oauthGrantingAccount.oauthAuthorizationClient + } + set { + guard var oauthGrantingAccount = delegate as? OAuthAuthorizationGranting else { + assertionFailure() + return + } + oauthGrantingAccount.oauthAuthorizationClient = newValue } - - return grantingType.oauthAuthorizationClient } - + public static func oauthAuthorizationCodeGrantRequest(for type: AccountType, client: OAuthAuthorizationClient) -> URLRequest { let grantingType: OAuthAuthorizationGranting.Type switch type { diff --git a/Frameworks/Account/Feedly/FeedlyAPICaller.swift b/Frameworks/Account/Feedly/FeedlyAPICaller.swift index dfe4e9c99..085df384e 100644 --- a/Frameworks/Account/Feedly/FeedlyAPICaller.swift +++ b/Frameworks/Account/Feedly/FeedlyAPICaller.swift @@ -28,25 +28,6 @@ final class FeedlyAPICaller { } return components } - - var oauthAuthorizationClient: OAuthAuthorizationClient { - switch self { - case .cloud: - /// Models private NetNewsWire client secrets. - /// https://developer.feedly.com/v3/auth/#authenticating-a-user-and-obtaining-an-auth-code - return OAuthAuthorizationClient(id: "{FEEDLY-ID}", - redirectUri: "{FEEDLY-REDIRECT-URI}", - state: nil, - secret: "{FEEDLY-SECRET}") - case .sandbox: - /// Models public sandbox API values found at: - /// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw - return OAuthAuthorizationClient(id: "sandbox", - redirectUri: "http://localhost", - state: nil, - secret: "ReVGXA6WekanCxbf") - } - } } private let transport: Transport diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift index abe0c465a..4c71f54b6 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift @@ -27,10 +27,6 @@ extension FeedlyAccountDelegate: OAuthAuthorizationGranting { private static let oauthAuthorizationGrantScope = "https://cloud.feedly.com/subscriptions" - static var oauthAuthorizationClient: OAuthAuthorizationClient { - return environment.oauthAuthorizationClient - } - static func oauthAuthorizationCodeGrantRequest(for client: OAuthAuthorizationClient) -> URLRequest { let authorizationRequest = OAuthAuthorizationRequest(clientId: client.id, redirectUri: client.redirectUri, diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift index 37e33ff16..403a4696b 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -17,14 +17,16 @@ final class FeedlyAccountDelegate: AccountDelegate { /// Feedly has a sandbox API and a production API. /// This property is referred to when clients need to know which environment it should be pointing to. + /// The value of this proptery must match any `OAuthAuthorizationClient` used. static var environment: FeedlyAPICaller.API { #if DEBUG // https://developer.feedly.com/v3/developer/ if let token = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !token.isEmpty { return .cloud } + // To debug on a different Feedly environment, do a workspace search for `FEEDLY_ENVIRONMENT` + // and ensure the environment matches the client. return .sandbox - #else return .cloud #endif @@ -53,6 +55,8 @@ final class FeedlyAccountDelegate: AccountDelegate { } } + var oauthAuthorizationClient: OAuthAuthorizationClient? + var accountMetadata: AccountMetadata? var refreshProgress = DownloadProgress(numberOfTasks: 0) @@ -484,9 +488,12 @@ final class FeedlyAccountDelegate: AccountDelegate { func accountDidInitialize(_ account: Account) { credentials = try? account.retrieveCredentials(type: .oauthAccessToken) - let client = FeedlyAccountDelegate.oauthAuthorizationClient - let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: client, log: log) - operationQueue.addOperation(refreshAccessToken) + if let client = oauthAuthorizationClient { + let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: client, log: log) + operationQueue.addOperation(refreshAccessToken) + } else { + os_log(.debug, log: log, "*** WARNING! Not refreshing token because the oauthAuthorizationClient has not been injected. ***") + } } static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift index 433687c32..01f506f6d 100644 --- a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift +++ b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift @@ -61,7 +61,7 @@ public struct OAuthAuthorizationResponse { public extension OAuthAuthorizationResponse { init(url: URL, client: OAuthAuthorizationClient) throws { - guard let host = url.host, client.redirectUri.contains(host) else { + guard let scheme = url.scheme, client.redirectUri.hasPrefix(scheme) else { throw URLError(.unsupportedURL) } guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { @@ -166,7 +166,7 @@ public protocol OAuthAuthorizationCodeGrantRequesting { protocol OAuthAuthorizationGranting: AccountDelegate { - static var oauthAuthorizationClient: OAuthAuthorizationClient { get } + var oauthAuthorizationClient: OAuthAuthorizationClient? { get set } static func oauthAuthorizationCodeGrantRequest(for client: OAuthAuthorizationClient) -> URLRequest diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index 02b87bd38..1c43cf6ee 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -101,9 +101,16 @@ extension AccountsAddViewController: NSTableViewDelegate { accountsReaderAPIWindowController.runSheetOnWindow(self.view.window!) accountsAddWindowController = accountsReaderAPIWindowController case .feedly: - let accountsFeedlyWindowController = AccountsFeedlyWebWindowController() - accountsFeedlyWindowController.runSheetOnWindow(self.view.window!) - accountsAddWindowController = accountsFeedlyWindowController + // To debug on a different Feedly environment, do a workspace search for `FEEDLY_ENVIRONMENT` + // and ensure the environment matches the client. + #if DEBUG + let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly, oauthClient: .feedlySandboxClient) + #else + let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly, oauthClient: .feedlyCloudClient) + #endif + addAccount.delegate = self + addAccount.presentationAnchor = self.view.window! + OperationQueue.main.addOperation(addAccount) default: break } @@ -113,3 +120,12 @@ extension AccountsAddViewController: NSTableViewDelegate { } } + +// MARK: OAuthAccountAuthorizationOperationDelegate + +extension AccountsAddViewController: OAuthAccountAuthorizationOperationDelegate { + + func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didFailWith error: Error) { + view.window?.presentError(error) + } +} diff --git a/Mac/Preferences/Accounts/AccountsFeedlyWebWindowController.swift b/Mac/Preferences/Accounts/AccountsFeedlyWebWindowController.swift index 7529a8a9a..586d44421 100644 --- a/Mac/Preferences/Accounts/AccountsFeedlyWebWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsFeedlyWebWindowController.swift @@ -29,7 +29,7 @@ class AccountsFeedlyWebWindowController: NSWindowController, WKNavigationDelegat } // MARK: Requesting an Access Token - let client = Account.oauthAuthorizationClient(for: .feedly) + let client = OAuthAuthorizationClient.feedlySandboxClient private func beginAuthorization() { let request = Account.oauthAuthorizationCodeGrantRequest(for: .feedly, client: client) @@ -92,6 +92,8 @@ class AccountsFeedlyWebWindowController: NSWindowController, WKNavigationDelegat // Now store the access token because we want the account delegate to use it. try account.storeCredentials(grant.accessToken) + account.oauthAuthorizationClient = client + self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) } catch { NSApplication.shared.presentError(error) diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 94820373d..11390b61c 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -615,8 +615,14 @@ 84F9EAF4213660A100CF2DE4 /* testGenericScript.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EAE1213660A100CF2DE4 /* testGenericScript.applescript */; }; 84F9EAF5213660A100CF2DE4 /* establishMainWindowStartingState.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EAE2213660A100CF2DE4 /* establishMainWindowStartingState.applescript */; }; 84FF69B11FC3793300DC198E /* FaviconURLFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */; }; + 9E7EFDA7237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7EFDA6237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift */; }; + 9E7EFDA8237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7EFDA6237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift */; }; + 9E7EFDC22375072300E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7EFDA6237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift */; }; 9EA33BB92318F8C10097B644 /* AccountsFeedlyWebWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */; }; 9EA33BBA2318F8C10097B644 /* AccountsFeedlyWeb.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */; }; + 9EBD5B062374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBD5B052374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift */; }; + 9EBD5B072374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBD5B052374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift */; }; + 9EBD5B082374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBD5B052374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift */; }; B528F81E23333C7E00E735DD /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = B528F81D23333C7E00E735DD /* page.html */; }; D553738B20186C20006D8857 /* Article+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553737C20186C1F006D8857 /* Article+Scriptability.swift */; }; D57BE6E0204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */; }; @@ -1538,8 +1544,10 @@ 84F9EAE2213660A100CF2DE4 /* establishMainWindowStartingState.applescript */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.applescript; path = establishMainWindowStartingState.applescript; sourceTree = ""; }; 84F9EAE4213660A100CF2DE4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.swift; sourceTree = ""; }; + 9E7EFDA6237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OAuthAuthorizationClient+NetNewsWire.swift"; sourceTree = ""; }; 9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsFeedlyWebWindowController.swift; sourceTree = ""; }; 9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountsFeedlyWeb.xib; sourceTree = ""; }; + 9EBD5B052374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAccountAuthorizationOperation.swift; sourceTree = ""; }; B24EFD482330FF99006C6242 /* NetNewsWire-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NetNewsWire-Bridging-Header.h"; sourceTree = ""; }; B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = ""; }; B528F81D23333C7E00E735DD /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; @@ -2441,6 +2449,8 @@ 84C9FC6822629C9A00D921D6 /* Shared */ = { isa = PBXGroup; children = ( + 9E7EFDA6237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift */, + 9EBD5B052374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift */, 846E77301F6EF5D600A165E2 /* Account.xcodeproj */, 841D4D542106B3D500DD04E6 /* Articles.xcodeproj */, 841D4D5E2106B3E100DD04E6 /* ArticlesDatabase.xcodeproj */, @@ -2798,8 +2808,9 @@ buildConfigurationList = 65ED407F235DEF6C0081F399 /* Build configuration list for PBXNativeTarget "NetNewsWire MAS" */; buildPhases = ( 65ED3FB5235DEF6C0081F399 /* Run Script: Update ArticleExtractorConfig.swift */, + 9E7EFDC3237509DC00E94DF8 /* Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift */, 65ED3FB6235DEF6C0081F399 /* Sources */, - 65ED4041235DEF6C0081F399 /* Run Script: Reset ArticleExtractorConfig.swift */, + 65ED4041235DEF6C0081F399 /* Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift */, 65ED4042235DEF6C0081F399 /* Frameworks */, 65ED404D235DEF6C0081F399 /* Resources */, 65ED406F235DEF6C0081F399 /* Run Script: Automated build numbers */, @@ -2848,8 +2859,9 @@ buildConfigurationList = 840D61A32029031E009BC708 /* Build configuration list for PBXNativeTarget "NetNewsWire-iOS" */; buildPhases = ( 517D2D80233A46ED00FF3E35 /* Run Script: Update ArticleExtractorConfig.swift */, + 9E7EFDC4237509F300E94DF8 /* Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift */, 840D61782029031C009BC708 /* Sources */, - 517D2D81233A47AD00FF3E35 /* Run Script: Reset ArticleExtractorConfig.swift */, + 517D2D81233A47AD00FF3E35 /* Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift */, 840D61792029031C009BC708 /* Frameworks */, 840D617A2029031C009BC708 /* Resources */, 51C451DF2264C7F200C03939 /* Embed Frameworks */, @@ -2871,8 +2883,9 @@ buildConfigurationList = 849C647A1ED37A5D003D8FC0 /* Build configuration list for PBXNativeTarget "NetNewsWire" */; buildPhases = ( 51D6803823330CFF0097A009 /* Run Script: Update ArticleExtractorConfig.swift */, + 9EBD5B092374CE3E008F3B58 /* Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift */, 849C645C1ED37A5D003D8FC0 /* Sources */, - 517D2D82233A53D600FF3E35 /* Run Script: Reset ArticleExtractorConfig.swift */, + 517D2D82233A53D600FF3E35 /* Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift */, 849C645D1ED37A5D003D8FC0 /* Frameworks */, 849C645E1ED37A5D003D8FC0 /* Resources */, 84C987A52000AC9E0066B150 /* Run Script: Automated build numbers */, @@ -3523,7 +3536,7 @@ shellPath = /bin/sh; shellScript = "FAILED=false\n\nif [ -z \"${MERCURY_CLIENT_ID}\" ]; then\nFAILED=true\nfi\n\nif [ -z \"${MERCURY_CLIENT_SECRET}\" ]; then\nFAILED=true\nfi\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feedbin Mercury credetials. ArticleExtractorConfig.swift not changed.\"\nexit 0\nfi\n\nsed -i .tmp \"s|{MERCURYID}|${MERCURY_CLIENT_ID}|g; s|{MERCURYSECRET}|${MERCURY_CLIENT_SECRET}|g\" \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift\"\n\nrm -f \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift.tmp\"\n\necho \"All env values found!\"\n"; }; - 517D2D81233A47AD00FF3E35 /* Run Script: Reset ArticleExtractorConfig.swift */ = { + 517D2D81233A47AD00FF3E35 /* Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3532,16 +3545,16 @@ ); inputPaths = ( ); - name = "Run Script: Reset ArticleExtractorConfig.swift"; + name = "Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "git checkout \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift\"\n"; + shellScript = "git checkout \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift\"\ngit checkout \"${SRCROOT}/Shared/OAuthAuthorizationClient+NetNewsWire.swift\"\n"; }; - 517D2D82233A53D600FF3E35 /* Run Script: Reset ArticleExtractorConfig.swift */ = { + 517D2D82233A53D600FF3E35 /* Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3550,14 +3563,14 @@ ); inputPaths = ( ); - name = "Run Script: Reset ArticleExtractorConfig.swift"; + name = "Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "git checkout \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift\"\n"; + shellScript = "git checkout \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift\"\ngit checkout \"${SRCROOT}/Shared/OAuthAuthorizationClient+NetNewsWire.swift\"\n"; }; 51D6803823330CFF0097A009 /* Run Script: Update ArticleExtractorConfig.swift */ = { isa = PBXShellScriptBuildPhase; @@ -3595,7 +3608,7 @@ shellPath = /bin/sh; shellScript = "FAILED=false\n\nif [ -z \"${MERCURY_CLIENT_ID}\" ]; then\nFAILED=true\nfi\n\nif [ -z \"${MERCURY_CLIENT_SECRET}\" ]; then\nFAILED=true\nfi\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feedbin Mercury credetials. ArticleExtractorConfig.swift not changed.\"\nexit 0\nfi\n\nsed -i .tmp \"s|{MERCURYID}|${MERCURY_CLIENT_ID}|g; s|{MERCURYSECRET}|${MERCURY_CLIENT_SECRET}|g\" \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift\"\n\nrm -f \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift.tmp\"\n\necho \"All env values found!\"\n\n"; }; - 65ED4041235DEF6C0081F399 /* Run Script: Reset ArticleExtractorConfig.swift */ = { + 65ED4041235DEF6C0081F399 /* Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3604,14 +3617,14 @@ ); inputPaths = ( ); - name = "Run Script: Reset ArticleExtractorConfig.swift"; + name = "Run Script: Reset ArticleExtractorConfig.swift and OAuthAuthorizationClient+NetNewsWire.swift"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "git checkout \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift\"\n"; + shellScript = "git checkout \"${SRCROOT}/Shared/Article Extractor/ArticleExtractorConfig.swift\"\ngit checkout \"${SRCROOT}/Shared/OAuthAuthorizationClient+NetNewsWire.swift\"\n"; }; 65ED406F235DEF6C0081F399 /* Run Script: Automated build numbers */ = { isa = PBXShellScriptBuildPhase; @@ -3677,6 +3690,60 @@ shellPath = /bin/sh; shellScript = "# See https://blog.curtisherbert.com/automated-build-numbers/\n\n# WARNING: If automated build numbers are restored then take \n# care to ensure any app extensions are versioned the same as the app.\n# ask Daniel (jalkut@red-sweater.com) if in doubt about this. \n#git=`sh /etc/profile; which git`\n#branch_name=`$git symbolic-ref HEAD | sed -e 's,.*/\\\\(.*\\\\),\\\\1,'`\n#git_count=`$git rev-list $branch_name |wc -l | sed 's/^ *//;s/ *$//'`\n#simple_branch_name=`$git rev-parse --abbrev-ref HEAD`\n\n#build_number=\"$git_count\"\n#if [ $CONFIGURATION != \"Release\" ]; then\n#build_number+=\"-$simple_branch_name\"\n#fi\n\n#plist=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n#dsym_plist=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n#/usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $build_number\" \"$plist\"\n#if [ -f \"$DSYM_INFO_PLIST\" ] ; then\n#/usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $build_number\" \"$dsym_plist\"\n#fi\n"; }; + 9E7EFDC3237509DC00E94DF8 /* Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "FAILED=false\n\nif [ -z \"${FEEDLY_CLIENT_ID}\" ]; then\necho \"Missing Feedly Client ID\"\nFAILED=true\nfi\n\nif [ -z \"${FEEDLY_CLIENT_SECRET}\" ]; then\necho \"Missing Feedly Client Secret\"\nFAILED=true\nfi\n\nFEEDLY_CLIENT_SOURCE=\"${SRCROOT}/Shared/OAuthAuthorizationClient+NetNewsWire.swift\"\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feedly client ID or secret. ${FEEDLY_CLIENT_SOURCE} not changed.\"\nexit 0\nfi\n\n# echo \"Substituting variables in: ${FEEDLY_CLIENT_SOURCE}\"\n\nif [ -e \"${FEEDLY_CLIENT_SOURCE}\" ]\nthen\n sed -i .tmp \"s|{FEEDLY_CLIENT_ID}|${FEEDLY_CLIENT_ID}|g; s|{FEEDLY_CLIENT_SECRET}|${FEEDLY_CLIENT_SECRET}|g\" $FEEDLY_CLIENT_SOURCE\n # echo \"`git diff ${FEEDLY_CLIENT_SOURCE}`\"\n rm -f \"${FEEDLY_CLIENT_SOURCE}.tmp\"\nelse\n echo \"File does not exist at ${FEEDLY_CLIENT_SOURCE}. Has it been moved or renamed?\"\n exit -1\nfi\n\necho \"All env values found!\"\n"; + }; + 9E7EFDC4237509F300E94DF8 /* Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "FAILED=false\n\nif [ -z \"${FEEDLY_CLIENT_ID}\" ]; then\necho \"Missing Feedly Client ID\"\nFAILED=true\nfi\n\nif [ -z \"${FEEDLY_CLIENT_SECRET}\" ]; then\necho \"Missing Feedly Client Secret\"\nFAILED=true\nfi\n\nFEEDLY_CLIENT_SOURCE=\"${SRCROOT}/Shared/OAuthAuthorizationClient+NetNewsWire.swift\"\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feedly client ID or secret. ${FEEDLY_CLIENT_SOURCE} not changed.\"\nexit 0\nfi\n\n# echo \"Substituting variables in: ${FEEDLY_CLIENT_SOURCE}\"\n\nif [ -e \"${FEEDLY_CLIENT_SOURCE}\" ]\nthen\n sed -i .tmp \"s|{FEEDLY_CLIENT_ID}|${FEEDLY_CLIENT_ID}|g; s|{FEEDLY_CLIENT_SECRET}|${FEEDLY_CLIENT_SECRET}|g\" $FEEDLY_CLIENT_SOURCE\n # echo \"`git diff ${FEEDLY_CLIENT_SOURCE}`\"\n rm -f \"${FEEDLY_CLIENT_SOURCE}.tmp\"\nelse\n echo \"File does not exist at ${FEEDLY_CLIENT_SOURCE}. Has it been moved or renamed?\"\n exit -1\nfi\n\necho \"All env values found!\"\n"; + }; + 9EBD5B092374CE3E008F3B58 /* Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Update OAuthAuthorizationClient+NetNewsWire.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "FAILED=false\n\nif [ -z \"${FEEDLY_CLIENT_ID}\" ]; then\necho \"Missing Feedly Client ID\"\nFAILED=true\nfi\n\nif [ -z \"${FEEDLY_CLIENT_SECRET}\" ]; then\necho \"Missing Feedly Client Secret\"\nFAILED=true\nfi\n\nFEEDLY_CLIENT_SOURCE=\"${SRCROOT}/Shared/OAuthAuthorizationClient+NetNewsWire.swift\"\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feedly client ID or secret. ${FEEDLY_CLIENT_SOURCE} not changed.\"\nexit 0\nfi\n\n# echo \"Substituting variables in: ${FEEDLY_CLIENT_SOURCE}\"\n\nif [ -e \"${FEEDLY_CLIENT_SOURCE}\" ]\nthen\n sed -i .tmp \"s|{FEEDLY_CLIENT_ID}|${FEEDLY_CLIENT_ID}|g; s|{FEEDLY_CLIENT_SECRET}|${FEEDLY_CLIENT_SECRET}|g\" $FEEDLY_CLIENT_SOURCE\n # echo \"`git diff ${FEEDLY_CLIENT_SOURCE}`\"\n rm -f \"${FEEDLY_CLIENT_SOURCE}.tmp\"\nelse\n echo \"File does not exist at ${FEEDLY_CLIENT_SOURCE}. Has it been moved or renamed?\"\n exit -1\nfi\n\necho \"All env values found!\"\n"; + }; D519E77022EE5B4100923F27 /* Run Script: Verify No Build Settings */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -3829,6 +3896,7 @@ 65ED400C235DEF6C0081F399 /* FolderInspectorViewController.swift in Sources */, 65ED400D235DEF6C0081F399 /* SmartFeedDelegate.swift in Sources */, 65ED400E235DEF6C0081F399 /* ImageDownloader.swift in Sources */, + 9E7EFDA8237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift in Sources */, 65ED400F235DEF6C0081F399 /* ArticleExtractorButton.swift in Sources */, 65ED4010235DEF6C0081F399 /* AccountsAddTableCellView.swift in Sources */, 65ED4011235DEF6C0081F399 /* AddFolderWindowController.swift in Sources */, @@ -3859,6 +3927,7 @@ 65ED402A235DEF6C0081F399 /* AddFeedWindowController.swift in Sources */, 65ED402B235DEF6C0081F399 /* ImportOPMLWindowController.swift in Sources */, 65ED402C235DEF6C0081F399 /* TimelineTableView.swift in Sources */, + 9EBD5B072374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift in Sources */, 65ED402D235DEF6C0081F399 /* DetailStatusBarView.swift in Sources */, 65ED402E235DEF6C0081F399 /* MainWindowController+Scriptability.swift in Sources */, 65ED402F235DEF6C0081F399 /* PreferencesWindowController.swift in Sources */, @@ -3928,6 +3997,7 @@ 51314704235C41FC00387FDC /* Intents.intentdefinition in Sources */, FF3ABF162325AF5D0074C542 /* ArticleSorter.swift in Sources */, 51C4525C226508DF00C03939 /* String-Extensions.swift in Sources */, + 9E7EFDC22375072300E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift in Sources */, 51C452792265091600C03939 /* MasterTimelineTableViewCell.swift in Sources */, 51FA73AB2332C2FD0090D516 /* ArticleExtractorConfig.swift in Sources */, 51C452852265093600C03939 /* FlattenedAccountFolderPickerData.swift in Sources */, @@ -3962,6 +4032,7 @@ 51C4527F2265092C00C03939 /* ArticleViewController.swift in Sources */, 51C4526A226508F600C03939 /* MasterFeedTableViewCellLayout.swift in Sources */, 51C452AE2265104D00C03939 /* ArticleStringFormatter.swift in Sources */, + 9EBD5B082374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift in Sources */, 512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */, 51C4529922650A0000C03939 /* ArticleStylesManager.swift in Sources */, 51EF0F802277A8330050506E /* MasterTimelineCellLayout.swift in Sources */, @@ -4064,11 +4135,13 @@ D553738B20186C20006D8857 /* Article+Scriptability.swift in Sources */, 845EE7C11FC2488C00854A1F /* SmartFeed.swift in Sources */, 84702AA41FA27AC0006B8943 /* MarkStatusCommand.swift in Sources */, + 9E7EFDA7237502C000E94DF8 /* OAuthAuthorizationClient+NetNewsWire.swift in Sources */, D5907D7F2004AC00005947E5 /* NSApplication+Scriptability.swift in Sources */, 8405DD9C22153BD7008CE1BF /* NSView-Extensions.swift in Sources */, 849A979F1ED9F130007D329B /* SidebarCell.swift in Sources */, 51E595A5228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */, 849A97651ED9EB96007D329B /* FeedTreeControllerDelegate.swift in Sources */, + 9EBD5B062374BD68008F3B58 /* OAuthAccountAuthorizationOperation.swift in Sources */, 849A97671ED9EB96007D329B /* UnreadCountView.swift in Sources */, 51FE10092346739D0056195D /* ActivityType.swift in Sources */, 840BEE4121D70E64009BBAFA /* CrashReportWindowController.swift in Sources */, diff --git a/Shared/OAuthAccountAuthorizationOperation.swift b/Shared/OAuthAccountAuthorizationOperation.swift new file mode 100644 index 000000000..ec047bb7b --- /dev/null +++ b/Shared/OAuthAccountAuthorizationOperation.swift @@ -0,0 +1,187 @@ +// +// OAuthAccountAuthorizationOperation.swift +// NetNewsWire +// +// Created by Kiel Gillard on 8/11/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation +import Account +import AuthenticationServices + +protocol OAuthAccountAuthorizationOperationDelegate: class { + func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didFailWith error: Error) +} + +final class OAuthAccountAuthorizationOperation: Operation, ASWebAuthenticationPresentationContextProviding { + + weak var presentationAnchor: ASPresentationAnchor? + weak var delegate: OAuthAccountAuthorizationOperationDelegate? + + private let accountType: AccountType + private let oauthClient: OAuthAuthorizationClient + private var session: ASWebAuthenticationSession? + + init(accountType: AccountType, oauthClient: OAuthAuthorizationClient) { + self.accountType = accountType + self.oauthClient = oauthClient + } + + override func main() { + assert(Thread.isMainThread) + assert(presentationAnchor != nil, "\(self) outlived presentation anchor.") + + guard !isCancelled else { + didFinish() + return + } + + let request = Account.oauthAuthorizationCodeGrantRequest(for: accountType, client: oauthClient) + + guard let url = request.url else { + return DispatchQueue.main.async { + self.didEndAuthentication(url: nil, error: URLError(.badURL)) + } + } + + guard let redirectUri = URL(string: oauthClient.redirectUri), let scheme = redirectUri.scheme else { + assertionFailure("Could not get callback URL scheme from \(oauthClient.redirectUri)") + return DispatchQueue.main.async { + self.didEndAuthentication(url: nil, error: URLError(.badURL)) + } + } + + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { url, error in + DispatchQueue.main.async { [weak self] in + self?.didEndAuthentication(url: url, error: error) + } + } + self.session = session + session.presentationContextProvider = self + + session.start() + } + + override func cancel() { + session?.cancel() + super.cancel() + } + + private func didEndAuthentication(url: URL?, error: Error?) { + guard !isCancelled else { + didFinish() + return + } + + do { + guard let url = url else { + if let error = error { + throw error + } + throw URLError(.badURL) + } + + let response = try OAuthAuthorizationResponse(url: url, client: oauthClient) + + Account.requestOAuthAccessToken(with: response, client: oauthClient, accountType: accountType, completionHandler: didEndRequestingAccessToken(_:)) + + } catch is ASWebAuthenticationSessionError { + didFinish() // Primarily, cancellation. + + } catch { + didFinish(error) + } + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + guard let anchor = presentationAnchor else { + fatalError("\(self) has outlived presentation anchor.") + } + return anchor + } + + private func didEndRequestingAccessToken(_ result: Result) { + guard !isCancelled else { + didFinish() + return + } + + switch result { + case .success(let tokenResponse): + saveAccount(for: tokenResponse) + case .failure(let error): + didFinish(error) + } + } + + private func saveAccount(for grant: OAuthAuthorizationGrant) { + // TODO: Find an already existing account for this username? + let account = AccountManager.shared.createAccount(type: .feedly) + do { + + // Store the refresh token first because it sends this token to the account delegate. + if let token = grant.refreshToken { + try account.storeCredentials(token) + } + + // Now store the access token because we want the account delegate to use it. + try account.storeCredentials(grant.accessToken) + + account.oauthAuthorizationClient = oauthClient + + didFinish() + } catch { + didFinish(error) + } + } + + // MARK: Managing Operation State + + private func didFinish() { + assert(Thread.isMainThread) + assert(!isFinished, "Finished operation is attempting to finish again.") + self.isExecutingOperation = false + self.isFinishedOperation = true + } + + private func didFinish(_ error: Error) { + assert(Thread.isMainThread) + assert(!isFinished, "Finished operation is attempting to finish again.") + delegate?.oauthAccountAuthorizationOperation(self, didFailWith: error) + didFinish() + } + + override func start() { + isExecutingOperation = true + DispatchQueue.main.async { + self.main() + } + } + + override var isExecuting: Bool { + return isExecutingOperation + } + + private var isExecutingOperation = false { + willSet { + willChangeValue(for: \.isExecuting) + } + didSet { + didChangeValue(for: \.isExecuting) + } + } + + override var isFinished: Bool { + return isFinishedOperation + } + + private var isFinishedOperation = false { + willSet { + willChangeValue(for: \.isFinished) + } + didSet { + didChangeValue(for: \.isFinished) + } + } +} diff --git a/Shared/OAuthAuthorizationClient+NetNewsWire.swift b/Shared/OAuthAuthorizationClient+NetNewsWire.swift new file mode 100644 index 000000000..5e00d1fe3 --- /dev/null +++ b/Shared/OAuthAuthorizationClient+NetNewsWire.swift @@ -0,0 +1,35 @@ +// +// OAuthAuthorizationClient+NetNewsWire.swift +// NetNewsWire +// +// Created by Kiel Gillard on 8/11/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation +import Account + +extension OAuthAuthorizationClient { + + static var feedlyCloudClient: OAuthAuthorizationClient { + /// Models private NetNewsWire client secrets. + /// These placeholders are substitued at build time using a Run Script phase with build settings. + /// https://developer.feedly.com/v3/auth/#authenticating-a-user-and-obtaining-an-auth-code + return OAuthAuthorizationClient(id: "{FEEDLY_CLIENT_ID}", + redirectUri: "netnewswire://auth/feedly", + state: nil, + secret: "{FEEDLY_CLIENT_SECRET}") + } + + static var feedlySandboxClient: OAuthAuthorizationClient { + /// We use this funky redirect URI because ASWebAuthenticationSession will try to load http://localhost URLs. + /// See https://developer.feedly.com/v3/sandbox/ for more information. + /// The return value models public sandbox API values found at: + /// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw + /// They are due to expire on November 30 2019. + return OAuthAuthorizationClient(id: "sandbox", + redirectUri: "urn:ietf:wg:oauth:2.0:oob", + state: nil, + secret: "ReVGXA6WekanCxbf") + } +}