diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index d7a02667c..7bce13e01 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -227,6 +227,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport) case .freshRSS: self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport) + case .feedly: + self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport) default: fatalError("Only Local and Feedbin accounts are supported") } @@ -307,6 +309,35 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, break } } + + public static func oauthAuthorizationCodeGrantRequest(for type: AccountType, client: OAuthAuthorizationClient) -> URLRequest { + let grantingType: OAuthAuthorizationGranting.Type + switch type { + case .feedly: + grantingType = FeedlyAccountDelegate.self + default: + fatalError("\(type) does not support OAuth authorization code granting.") + } + + return grantingType.oauthAuthorizationCodeGrantRequest(for: client) + } + + public static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, + client: OAuthAuthorizationClient, + accountType: AccountType, + transport: Transport = URLSession.webserviceTransport(), + completionHandler: @escaping (Result) -> ()) { + let grantingType: OAuthAuthorizationGranting.Type + + switch accountType { + case .feedly: + grantingType = FeedlyAccountDelegate.self + default: + fatalError("\(accountType) does not support OAuth authorization code granting.") + } + + grantingType.requestOAuthAccessToken(with: response, client: client, transport: transport, completionHandler: completionHandler) + } public func refreshAll(completion: @escaping (Result) -> Void) { self.delegate.refreshAll(for: self, completion: completion) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index ca261c228..1060261bf 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -73,6 +73,10 @@ 84EAC4822148CC6300F154AB /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84EAC4812148CC6300F154AB /* RSDatabase.framework */; }; 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */; }; 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */; }; + 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */; }; + 9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */; }; + 9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */; }; + 9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -190,6 +194,10 @@ 84D09622217418DC00D77525 /* FeedbinTagging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTagging.swift; sourceTree = ""; }; 84EAC4812148CC6300F154AB /* RSDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleFetcher.swift; sourceTree = ""; }; + 9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedlyAccountDelegate.swift; sourceTree = ""; }; + 9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAPICaller.swift; sourceTree = ""; }; + 9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedlyAccountDelegate+OAuth.swift"; sourceTree = ""; }; + 9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAuthorizationCodeGranting.swift; sourceTree = ""; }; D511EEB5202422BB00712EC3 /* Account_project_debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_debug.xcconfig; sourceTree = ""; }; D511EEB6202422BB00712EC3 /* Account_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_target.xcconfig; sourceTree = ""; }; D511EEB7202422BB00712EC3 /* Account_project_release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_release.xcconfig; sourceTree = ""; }; @@ -357,6 +365,7 @@ 8419742B1F6DDE84006346C4 /* LocalAccount */, 84245C7D1FDDD2580074AFBB /* Feedbin */, 552032EA229D5D5A009559E0 /* ReaderAPI */, + 9EA31339231E368100268BA0 /* Feedly */, 848935031F62484F00CEBD24 /* AccountTests */, 848934F71F62484F00CEBD24 /* Products */, 8469F80F1F6DC3C10084783E /* Frameworks */, @@ -390,6 +399,17 @@ path = AccountTests; sourceTree = ""; }; + 9EA31339231E368100268BA0 /* Feedly */ = { + isa = PBXGroup; + children = ( + 9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */, + 9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */, + 9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */, + 9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */, + ); + path = Feedly; + sourceTree = SOURCE_ROOT; + }; D511EEB4202422BB00712EC3 /* xcconfig */ = { isa = PBXGroup; children = ( @@ -572,8 +592,11 @@ 84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */, 552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */, 84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */, + 9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */, + 9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */, 8469F81C1F6DD15E0084783E /* Account.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */, @@ -603,6 +626,7 @@ 552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */, 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */, 552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */, + 9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, diff --git a/Frameworks/Account/Feedly/FeedlyAPICaller.swift b/Frameworks/Account/Feedly/FeedlyAPICaller.swift new file mode 100644 index 000000000..db459f04c --- /dev/null +++ b/Frameworks/Account/Feedly/FeedlyAPICaller.swift @@ -0,0 +1,109 @@ +// +// FeedlyAPICaller.swift +// Account +// +// Created by Kiel Gillard on 13/9/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +final class FeedlyAPICaller { + + enum API { + case sandbox + case cloud + + static var `default`: API { + return .sandbox + } + + var baseUrlComponents: URLComponents { + var components = URLComponents() + components.scheme = "https" + switch self{ + case .sandbox: + // https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw + components.host = "sandbox7.feedly.com" + case .cloud: + // https://developer.feedly.com/cloud/ + components.host = "cloud.feedly.com" + } + return components + } + } + + private let transport: Transport + private let baseUrlComponents: URLComponents + + init(transport: Transport, api: API) { + self.transport = transport + self.baseUrlComponents = api.baseUrlComponents + } + + var credentials: Credentials? + + var server: String? { + return baseUrlComponents.host + } +} + +extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting { + + static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest) -> URLRequest { + let api = API.default + var components = api.baseUrlComponents + components.path = "/v3/auth/auth" + components.queryItems = request.queryItems + + guard let url = components.url else { + fatalError("\(components) does not produce a valid URL.") + } + + var request = URLRequest(url: url) + request.addValue("application/json", forHTTPHeaderField: "Accept-Type") + + return request + } + + typealias AccessTokenResponse = FeedlyOAuthAccessTokenResponse + + func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completionHandler: @escaping (Result) -> ()) { + var components = baseUrlComponents + components.path = "/v3/auth/token" + + guard let url = components.url else { + fatalError("\(components) does not produce a valid URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept-Type") + + do { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + request.httpBody = try encoder.encode(authorizationRequest) + } catch { + DispatchQueue.main.async { + completionHandler(.failure(error)) + } + return + } + + transport.send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in + switch result { + case .success(let (_, tokenResponse)): + if let response = tokenResponse { + completionHandler(.success(response)) + } else { + completionHandler(.failure(URLError(.cannotDecodeContentData))) + } + case .failure(let error): + completionHandler(.failure(error)) + } + } + } +} diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift new file mode 100644 index 000000000..8e97e5f2c --- /dev/null +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift @@ -0,0 +1,64 @@ +// +// FeedlyAccountDelegate+OAuth.swift +// Account +// +// Created by Kiel Gillard on 14/9/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +/// Models the access token response from Feedly. +/// https://developer.feedly.com/v3/auth/#exchanging-an-auth-code-for-a-refresh-token-and-an-access-token +public struct FeedlyOAuthAccessTokenResponse: Decodable, OAuthAccessTokenResponse { + /// The ID of the Feedly user. + public var id: String + + // Required properties of the OAuth 2.0 Authorization Framework section 4.1.4. + public var accessToken: String + public var tokenType: String + public var expiresIn: Int + public var refreshToken: String? + public var scope: String +} + +extension FeedlyAccountDelegate: OAuthAuthorizationGranting { + + private static let oauthAuthorizationGrantScope = "https://cloud.feedly.com/subscriptions" + + static func oauthAuthorizationCodeGrantRequest(for client: OAuthAuthorizationClient) -> URLRequest { + let authorizationRequest = OAuthAuthorizationRequest(clientId: client.id, + redirectUri: client.redirectUri, + scope: oauthAuthorizationGrantScope, + state: client.state) + return FeedlyAPICaller.authorizationCodeUrlRequest(for: authorizationRequest) + } + + static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, client: OAuthAuthorizationClient, transport: Transport, completionHandler: @escaping (Result) -> ()) { + let request = OAuthAccessTokenRequest(authorizationResponse: response, + scope: oauthAuthorizationGrantScope, + client: client) + let caller = FeedlyAPICaller(transport: transport, api: .default) + caller.requestAccessToken(request) { result in + switch result { + case .success(let response): + let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken) + + let refreshToken: Credentials? = { + guard let token = response.refreshToken else { + return nil + } + return Credentials(type: .oauthRefreshToken, username: response.id, secret: token) + }() + + let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken) + + completionHandler(.success(grant)) + + case .failure(let error): + completionHandler(.failure(error)) + } + } + } +} diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift new file mode 100644 index 000000000..b8aa01267 --- /dev/null +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -0,0 +1,141 @@ +// +// FeedlyAccountDelegate.swift +// Account +// +// Created by Kiel Gillard on 3/9/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Articles +import RSCore +import RSParser +import RSWeb +import SyncDatabase +import os.log + +final class FeedlyAccountDelegate: AccountDelegate { + + // Collections are one-level deep. + let isSubfoldersSupported = false + + // Feedly uses collections and streams. But it does have tags? + let isTagBasedSystem = false + + // Could be true. See https://developer.feedly.com/v3/opml/ + let isOPMLImportSupported = false + + var isOPMLImportInProgress = false + + var server: String? { + return caller.server + } + + var credentials: Credentials? + + var accountMetadata: AccountMetadata? + + var refreshProgress = DownloadProgress(numberOfTasks: 0) + + private let database: SyncDatabase + private let caller: FeedlyAPICaller + + init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API = .default) { + + let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") + database = SyncDatabase(databaseFilePath: databaseFilePath) + + if let transport = transport { + caller = FeedlyAPICaller(transport: transport, api: api) + + } else { + + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 60.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 1 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + let session = URLSession(configuration: sessionConfiguration) + caller = FeedlyAPICaller(transport: session, api: api) + } + + } + + // MARK: Account API + + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + fatalError() + } + + func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + fatalError() + } + + func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + fatalError() + } + + func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> Void) { + fatalError() + } + + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + fatalError() + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + fatalError() + } + + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + fatalError() + } + + func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { + fatalError() + } + + func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { + fatalError() + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + fatalError() + } + + func accountDidInitialize(_ account: Account) { +// accountMetadata = account.metadata + credentials = try? account.retrieveCredentials(type: .oauthAccessToken) + } + + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result) -> Void) { + fatalError() + } +} diff --git a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift new file mode 100644 index 000000000..4e055aa3d --- /dev/null +++ b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift @@ -0,0 +1,171 @@ +// +// OAuthAuthorizationCodeGranting.swift +// Account +// +// Created by Kiel Gillard on 14/9/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +/// Client-specific information for requesting an authorization code grant. +/// Accounts are responsible for the scope. +public struct OAuthAuthorizationClient { + public var id: String + public var redirectUri: String + public var state: String? + public var secret: String + + public init(id: String, redirectUri: String, state: String?, secret: String) { + self.id = id + self.redirectUri = redirectUri + self.state = state + self.secret = secret + } +} + +/// Models section 4.1.1 of the OAuth 2.0 Authorization Framework +/// https://tools.ietf.org/html/rfc6749#section-4.1.1 +public struct OAuthAuthorizationRequest { + public let responseType = "code" + public var clientId: String + public var redirectUri: String + public var scope: String + public var state: String? + + public init(clientId: String, redirectUri: String, scope: String, state: String?) { + self.clientId = clientId + self.redirectUri = redirectUri + self.scope = scope + self.state = state + } + + public var queryItems: [URLQueryItem] { + return [ + URLQueryItem(name: "response_type", value: responseType), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "scope", value: scope), + URLQueryItem(name: "redirect_uri", value: redirectUri), + ] + } +} + +/// Models section 4.1.2 of the OAuth 2.0 Authorization Framework +/// https://tools.ietf.org/html/rfc6749#section-4.1.2 +public struct OAuthAuthorizationResponse { + public var code: String + public var state: String? +} + +public extension OAuthAuthorizationResponse { + + init(url: URL, client: OAuthAuthorizationClient) throws { + guard let host = url.host, client.redirectUri.contains(host) else { + throw URLError(.unsupportedURL) + } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw URLError(.badURL) + } + guard let queryItems = components.queryItems, !queryItems.isEmpty else { + throw URLError(.unsupportedURL) + } + let code = queryItems.firstElementPassingTest { $0.name.lowercased() == "code" } + guard let codeValue = code?.value, !codeValue.isEmpty else { + throw URLError(.unsupportedURL) + } + + let state = queryItems.firstElementPassingTest { $0.name.lowercased() == "state" } + let stateValue = state?.value + + self.init(code: codeValue, state: stateValue) + } +} + +/// Models section 4.1.2.1 of the OAuth 2.0 Authorization Framework +/// https://tools.ietf.org/html/rfc6749#section-4.1.2.1 +public struct OAuthAuthorizationErrorResponse: Error { + public var error: OAuthAuthorizationError + public var state: String? + public var errorDescription: String? + + public var localizedDescription: String { + return errorDescription ?? error.rawValue + } +} + +/// Error values as enumerated in section 4.1.2.1 of the OAuth 2.0 Authorization Framework. +/// https://tools.ietf.org/html/rfc6749#section-4.1.2.1 +public enum OAuthAuthorizationError: String { + case invalidRequest = "invalid_request" + case unauthorizedClient = "unauthorized_client" + case accessDenied = "access_denied" + case unsupportedResponseType = "unsupported_response_type" + case invalidScope = "invalid_scope" + case serverError = "server_error" + case temporarilyUnavailable = "temporarily_unavailable" +} + +/// Models section 4.1.3 of the OAuth 2.0 Authorization Framework +/// https://tools.ietf.org/html/rfc6749#section-4.1.3 +public struct OAuthAccessTokenRequest: Encodable { + public let grantType = "authorization_code" + public var code: String + public var redirectUri: String + public var state: String? + public var clientId: String + + // Possibly not part of the standard but specific to certain implementations (e.g.: Feedly). + public var clientSecret: String + public var scope: String + + public init(authorizationResponse: OAuthAuthorizationResponse, scope: String, client: OAuthAuthorizationClient) { + self.code = authorizationResponse.code + self.redirectUri = client.redirectUri + self.state = authorizationResponse.state + self.clientId = client.id + self.clientSecret = client.secret + self.scope = scope + } +} + +/// Models the minimum subset of properties of a response in section 4.1.4 of the OAuth 2.0 Authorization Framework +/// Concrete types model other paramters beyond the scope of the OAuth spec. +/// For example, Feedly provides the ID of the user who has consented to the grant. +/// https://tools.ietf.org/html/rfc6749#section-4.1.4 +public protocol OAuthAccessTokenResponse { + var accessToken: String { get } + var tokenType: String { get } + var expiresIn: Int { get } + var refreshToken: String? { get } + var scope: String { get } +} + +/// The access and refresh tokens from a successful authorization grant. +public struct OAuthAuthorizationGrant { + public var accessToken: Credentials + public var refreshToken: Credentials? +} + +/// Conformed to by API callers to provide a consistent interface for `AccountDelegate` types to enable OAuth Authorization Grants. Conformers provide an associated type that models any custom parameters/properties, as well as the standard ones, in the response to a request for an access token. +/// https://tools.ietf.org/html/rfc6749#section-4.1 +public protocol OAuthAuthorizationCodeGrantRequesting { + associatedtype AccessTokenResponse: OAuthAccessTokenResponse + + /// Provides the URL request that allows users to consent to the client having access to their information. Typically loaded by a web view. + /// - Parameter request: The + static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest) -> URLRequest + + + /// Performs the request for the access token given an authorization code. + /// - Parameter authorizationRequest: The authorization code and other information the authorization server requires to grant the client access tokes on the user's behalf. + /// - Parameter completionHandler: On success, the access token response appropriate for concrete type's service. On failure, possibly a `URLError` or `OAuthAuthorizationErrorResponse` value. + func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completionHandler: @escaping (Result) -> ()) +} + +protocol OAuthAuthorizationGranting: AccountDelegate { + + static func oauthAuthorizationCodeGrantRequest(for client: OAuthAuthorizationClient) -> URLRequest + + static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, client: OAuthAuthorizationClient, transport: Transport, completionHandler: @escaping (Result) -> ()) +} diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index 404c5eedf..d936a01a6 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -15,7 +15,7 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? - private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .freshRSS] + private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .freshRSS, .feedly] init() { super.init(nibName: "AccountsAdd", bundle: nil) @@ -68,6 +68,9 @@ extension AccountsAddViewController: NSTableViewDelegate { case .freshRSS: cell.accountNameLabel?.stringValue = NSLocalizedString("FreshRSS", comment: "FreshRSS") cell.accountImageView?.image = AppAssets.accountFreshRSS + case .feedly: + cell.accountNameLabel?.stringValue = NSLocalizedString("Feedly", comment: "Feedly") + cell.accountImageView?.image = AppAssets.accountFreshRSS default: break } @@ -97,6 +100,10 @@ extension AccountsAddViewController: NSTableViewDelegate { accountsReaderAPIWindowController.accountType = .freshRSS accountsReaderAPIWindowController.runSheetOnWindow(self.view.window!) accountsAddWindowController = accountsReaderAPIWindowController + case .feedly: + let accountsFeedlyWindowController = AccountsFeedlyWebWindowController() + accountsFeedlyWindowController.runSheetOnWindow(self.view.window!) + accountsAddWindowController = accountsFeedlyWindowController default: break } diff --git a/Mac/Preferences/Accounts/AccountsDetailViewController.swift b/Mac/Preferences/Accounts/AccountsDetailViewController.swift index d1cd902b8..902293e39 100644 --- a/Mac/Preferences/Accounts/AccountsDetailViewController.swift +++ b/Mac/Preferences/Accounts/AccountsDetailViewController.swift @@ -66,6 +66,9 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate accountsFreshRSSWindowController.account = account accountsFreshRSSWindowController.runSheetOnWindow(self.view.window!) accountsWindowController = accountsFreshRSSWindowController + case .feedly: + assertionFailure("Implement feedly logout window controller") + break default: break } diff --git a/Mac/Preferences/Accounts/AccountsFeedlyWeb.xib b/Mac/Preferences/Accounts/AccountsFeedlyWeb.xib new file mode 100644 index 000000000..73455ac32 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsFeedlyWeb.xib @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Preferences/Accounts/AccountsFeedlyWebWindowController.swift b/Mac/Preferences/Accounts/AccountsFeedlyWebWindowController.swift new file mode 100644 index 000000000..1e347fcd1 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsFeedlyWebWindowController.swift @@ -0,0 +1,116 @@ +// +// AccountsFeedlyWebWindowController.swift +// NetNewsWire +// +// Created by Kiel Gillard on 30/8/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Cocoa +import Account +import WebKit + +class AccountsFeedlyWebWindowController: NSWindowController, WKNavigationDelegate { + + @IBOutlet private weak var webView: WKWebView! + + private weak var hostWindow: NSWindow? + + convenience init() { + self.init(windowNibName: NSNib.Name("AccountsFeedlyWeb")) + } + + // MARK: API + + func runSheetOnWindow(_ hostWindow: NSWindow, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) { + self.hostWindow = hostWindow + hostWindow.beginSheet(window!, completionHandler: handler) + beginAuthorization() + } + + // MARK: Requesting an Access Token + + private let client = OAuthAuthorizationClient.feedlySandboxClient + + private func beginAuthorization() { + let request = Account.oauthAuthorizationCodeGrantRequest(for: .feedly, client: client) + webView.load(request) + } + + private func requestAccessToken(for response: OAuthAuthorizationResponse) { + Account.requestOAuthAccessToken(with: response, client: client, accountType: .feedly) { [weak self] result in + switch result { + case .success(let tokenResponse): + self?.saveAccount(for: tokenResponse) + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + + // MARK: Actions + + @IBAction func cancel(_ sender: Any) { + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel) + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + + do { + guard let url = navigationAction.request.url else { return } + + let response = try OAuthAuthorizationResponse(url: url, client: client) + + requestAccessToken(for: response) + + // No point the web view trying to load this. + return decisionHandler(.cancel) + + } catch let error as OAuthAuthorizationErrorResponse { + NSApplication.shared.presentError(error) + + } catch { + NSApplication.shared.presentError(error) + } + + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + print(error) + } + + private func saveAccount(for grant: OAuthAuthorizationGrant) { + // TODO: Find an already existing account for this username? + let account = AccountManager.shared.createAccount(type: .feedly) + do { + try account.storeCredentials(grant.accessToken) + + if let token = grant.refreshToken { + try account.storeCredentials(token) + } + + self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) + } catch { + NSApplication.shared.presentError(error) + } + } +} + +private extension OAuthAuthorizationClient { + + /// Models public sandbox API values found at: + /// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw + static var feedlySandboxClient: OAuthAuthorizationClient { + return OAuthAuthorizationClient(id: "sandbox", + redirectUri: "http://localhost", + state: nil, + secret: "ReVGXA6WekanCxbf") + } + + /// Models private NetNewsWire client secrets. + /// https://developer.feedly.com/v3/auth/#authenticating-a-user-and-obtaining-an-auth-code + static var netNewsWireClient: OAuthAuthorizationClient { + fatalError("This app is not registered as a client with Feedly. Follow the URL in the code comments for this property.") + } +} diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 9caac5e83..e250e0e26 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -370,6 +370,8 @@ 84FB9A2F1EDCD6C4003D53B9 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84FB9A2D1EDCD6B8003D53B9 /* Sparkle.framework */; }; 84FB9A301EDCD6C4003D53B9 /* Sparkle.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 84FB9A2D1EDCD6B8003D53B9 /* Sparkle.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 84FF69B11FC3793300DC198E /* FaviconURLFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */; }; + 9EA33BB92318F8C10097B644 /* AccountsFeedlyWebWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */; }; + 9EA33BBA2318F8C10097B644 /* AccountsFeedlyWeb.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */; }; 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 */; }; D5907D7F2004AC00005947E5 /* NSApplication+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5907D7E2004AC00005947E5 /* NSApplication+Scriptability.swift */; }; @@ -1051,6 +1053,8 @@ 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.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 = ""; }; + 9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsFeedlyWebWindowController.swift; sourceTree = ""; }; + 9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountsFeedlyWeb.xib; sourceTree = ""; }; D519E74722EE553300923F27 /* NetNewsWire_safariextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target.xcconfig; sourceTree = ""; }; D553737C20186C1F006D8857 /* Article+Scriptability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Article+Scriptability.swift"; sourceTree = ""; }; D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScriptCommand+NetNewsWire.swift"; sourceTree = ""; }; @@ -1891,6 +1895,8 @@ 5144EA2E2279FAB600D19003 /* AccountsDetailViewController.swift */, 5144EA50227B8E4500D19003 /* AccountsFeedbin.xib */, 5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */, + 9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */, + 9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */, 5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */, 5144EA372279FC6200D19003 /* AccountsAddLocalWindowController.swift */, ); @@ -2508,6 +2514,7 @@ 5127B23A222B4849006D641D /* DetailKeyboardShortcuts.plist in Resources */, 845479881FEB77C000AD8B59 /* TimelineKeyboardShortcuts.plist in Resources */, 848362FF2262A30E00DA1D35 /* template.html in Resources */, + 9EA33BBA2318F8C10097B644 /* AccountsFeedlyWeb.xib in Resources */, 848363082262A3DD00DA1D35 /* Main.storyboard in Resources */, 51EF0F8E2279C9260050506E /* AccountsAdd.xib in Resources */, 84C9FC8F22629E8F00D921D6 /* NetNewsWire.sdef in Resources */, @@ -2780,6 +2787,7 @@ 84AD1EBA2031649C00BC20B7 /* SmartFeedPasteboardWriter.swift in Sources */, 84CC88181FE59CBF00644329 /* SmartFeedsController.swift in Sources */, 849A97661ED9EB96007D329B /* SidebarViewController.swift in Sources */, + 9EA33BB92318F8C10097B644 /* AccountsFeedlyWebWindowController.swift in Sources */, 849A97641ED9EB96007D329B /* SidebarOutlineView.swift in Sources */, 5127B238222B4849006D641D /* DetailKeyboardDelegate.swift in Sources */, 8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */, diff --git a/submodules/RSWeb b/submodules/RSWeb index 168ce1a62..b1230d5aa 160000 --- a/submodules/RSWeb +++ b/submodules/RSWeb @@ -1 +1 @@ -Subproject commit 168ce1a628847d986d032247498b00293e7659f2 +Subproject commit b1230d5aae49ee6c908fe694cd4f77b98d17cc42