diff --git a/Sources/Imperial/Services/Shopify/Session+Shopify.swift b/Sources/Imperial/Services/Shopify/Session+Shopify.swift index fb62527..86ca3b9 100644 --- a/Sources/Imperial/Services/Shopify/Session+Shopify.swift +++ b/Sources/Imperial/Services/Shopify/Session+Shopify.swift @@ -1,17 +1,26 @@ import Vapor extension Session.Keys { - static let domain = "shop_domain" + static let domain = "shop_domain" + static let nonce = "nonce" } extension Session { - - public func shopDomain() throws -> String { - guard let domain = self[Keys.domain] else { throw Abort(.notFound) } - return domain - } - - func setShopDomain(_ domain: String) { - self[Keys.domain] = domain - } + + func shopDomain() throws -> String { + guard let domain = self[Keys.domain] else { throw Abort(.notFound) } + return domain + } + + func setShopDomain(_ domain: String) { + self[Keys.domain] = domain + } + + func setNonce(_ nonce: String?) { + self[Keys.nonce] = nonce + } + + func nonce() -> String? { + return self[Keys.nonce] + } } diff --git a/Sources/Imperial/Services/Shopify/Shopify.swift b/Sources/Imperial/Services/Shopify/Shopify.swift index c6ee1d4..16d5270 100644 --- a/Sources/Imperial/Services/Shopify/Shopify.swift +++ b/Sources/Imperial/Services/Shopify/Shopify.swift @@ -1,22 +1,26 @@ import Vapor public class Shopify: FederatedService { - - public var tokens: FederatedServiceTokens { - return self.router.tokens - } - - public var router: FederatedServiceRouter - - public required init(router: Router, - authenticate: String, - callback: String, - scope: [String], - completion: @escaping (Request, String) throws -> (EventLoopFuture)) throws { - - self.router = try ShopifyRouter(callback: callback, completion: completion) - self.router.scope = scope - - try self.router.configureRoutes(withAuthURL: authenticate, on: router) - } + + public var tokens: FederatedServiceTokens { + return self.router.tokens + } + + public var router: FederatedServiceRouter { + return shopifyRouter + } + + public var shopifyRouter: ShopifyRouter + + public required init(router: Router, + authenticate: String, + callback: String, + scope: [String], + completion: @escaping (Request, String) throws -> (EventLoopFuture)) throws { + + self.shopifyRouter = try ShopifyRouter(callback: callback, completion: completion) + self.shopifyRouter.scope = scope + + try self.router.configureRoutes(withAuthURL: authenticate, on: router) + } } diff --git a/Sources/Imperial/Services/Shopify/ShopifyRouter.swift b/Sources/Imperial/Services/Shopify/ShopifyRouter.swift index 4e6942f..cfe06a1 100644 --- a/Sources/Imperial/Services/Shopify/ShopifyRouter.swift +++ b/Sources/Imperial/Services/Shopify/ShopifyRouter.swift @@ -1,108 +1,119 @@ import Vapor public class ShopifyRouter: FederatedServiceRouter { - - public let tokens: FederatedServiceTokens - public let callbackCompletion: (Request, String) throws -> (Future) - public var scope: [String] = [] - public let callbackURL: String - public var accessTokenURL: String { - return _accessTokenURL - } - public var authURL: String { - return _authURL - } - - public var _accessTokenURL: String! - public var _authURL: String! - private var nonce: String! - - required public init(callback: String, completion: @escaping (Request, String) throws -> (Future)) throws { - self.tokens = try ShopifyAuth() - self.callbackURL = callback - self.callbackCompletion = completion - } - - /// The route thats called to initiate the auth flow - /// ex. https://78d55c18.ngrok.io/login-shopify?shop=davidmuzi.myshopify.com - /// - /// - Parameter request: The request from the browser. - /// - Returns: A response that, by default, redirects the user to `authURL`. - /// - Throws: N/A - public func authenticate(_ request: Request) throws -> Future { - - nonce = String(UUID().uuidString.prefix(6)) - authURLFrom(request) - - let redirect: Response = request.redirect(to: authURL) - return request.eventLoop.newSucceededFuture(result: redirect) - } - - /// Gets an access token from an OAuth provider. - /// This method is the main body of the `callback` handler. - /// - /// - Parameters: request: The request for the route this method is called in. - public func fetchToken(from request: Request) throws -> EventLoopFuture { - - // Extract the parameters to verify - guard let code = request.query[String.self, at: "code"], - let shop = request.query[String.self, at: "shop"], - let hmac = request.query[String.self, at: "hmac"], - let state = request.query[String.self, at: "state"] else { throw Abort(.badRequest) } - - // Verify the request - guard state == nonce else { throw Abort(.badRequest) } - guard URL(string: shop)?.isValidShopifyDomain() == true else { throw Abort(.badRequest) } - guard request.http.url.generateHMAC(key: tokens.clientSecret) == hmac else { throw Abort(.badRequest) } - - setAccessTokenURLFrom(request) - - // obtain access token - let body = ShopifyCallbackBody(code: code, clientId: tokens.clientID, clientSecret: tokens.clientSecret) - return try body.encode(using: request).flatMap(to: Response.self) { request in - guard let url = URL(string: self.accessTokenURL) else { - throw Abort(.internalServerError, reason: "Unable to convert String '\(self.accessTokenURL)' to URL") - } - request.http.method = .POST - request.http.url = url - return try request.make(Client.self).send(request) - }.flatMap(to: String.self) { response in - return response.content.get(String.self, at: ["access_token"]) - } - } - - /// The route that the OAuth provider calls when the user has benn authenticated. - /// - /// - Parameter request: The request from the OAuth provider. - /// - Returns: A response that should redirect the user back to the app. - /// - Throws: An errors that occur in the implementation code. - public func callback(_ request: Request) throws -> EventLoopFuture { - - return try self.fetchToken(from: request).flatMap(to: ResponseEncodable.self) { accessToken in - - guard let domain = request.query[String.self, at: "shop"] else { throw Abort(.badRequest) } - - let session = try request.session() - session.setAccessToken(accessToken) - session.setShopDomain(domain) - - return try self.callbackCompletion(request, accessToken) - }.flatMap(to: Response.self) { response in - return try response.encode(for: request) - } - } - - private func authURLFrom(_ request: Request) { - guard let shop = request.query[String.self, at: "shop"] else { return } - - _authURL = "https://\(shop)/admin/oauth/authorize?" + "client_id=\(tokens.clientID)&" + - "scope=\(scope.joined(separator: ","))&" + - "redirect_uri=\(callbackURL)&" + - "state=\(nonce!)" - } - - private func setAccessTokenURLFrom(_ request: Request) { - guard let shop = request.query[String.self, at: "shop"] else { return } - _accessTokenURL = "https://\(shop)/admin/oauth/access_token" - } + + public let tokens: FederatedServiceTokens + public let callbackCompletion: (Request, String) throws -> (Future) + public var scope: [String] = [] + public let callbackURL: String + public var accessTokenURL: String { + return _accessTokenURL + } + public var authURL: String { + return _authURL + } + + private var _accessTokenURL: String! + private var _authURL: String! + + required public init(callback: String, completion: @escaping (Request, String) throws -> (Future)) throws { + self.tokens = try ShopifyAuth() + self.callbackURL = callback + self.callbackCompletion = completion + } + + /// The route thats called to initiate the auth flow + /// ex. https://ed4da397.ngrok.io/login-shopify?shop=davidmuzi.myshopify.com + /// + /// - Parameter request: The request from the browser. + /// - Returns: A response that, by default, redirects the user to `authURL`. + /// - Throws: N/A + public func authenticate(_ request: Request) throws -> Future { + + _authURL = try generateAuthenticationURL(request: request).absoluteString + let redirect: Response = request.redirect(to: _authURL) + return request.eventLoop.newSucceededFuture(result: redirect) + } + + /// Gets an access token from an OAuth provider. + /// This method is the main body of the `callback` handler. + /// + /// - Parameters: request: The request for the route this method is called in. + public func fetchToken(from request: Request) throws -> EventLoopFuture { + + // Extract the parameters to verify + guard let code = request.query[String.self, at: "code"], + let shop = request.query[String.self, at: "shop"], + let hmac = request.query[String.self, at: "hmac"] else { throw Abort(.badRequest) } + + // Verify the request + if let state = request.query[String.self, at: "state"] { + let nonce = try request.session().nonce() + guard state == nonce else { throw Abort(.badRequest) } + } + guard URL(string: shop)?.isValidShopifyDomain() == true else { throw Abort(.badRequest) } + guard request.http.url.generateHMAC(key: tokens.clientSecret) == hmac else { throw Abort(.badRequest) } + + _accessTokenURL = try accessTokenURLFrom(request) + + // exchange code for access token + let body = ShopifyCallbackBody(code: code, clientId: tokens.clientID, clientSecret: tokens.clientSecret) + return try body.encode(using: request).flatMap(to: Response.self) { request in + guard let url = URL(string: self.accessTokenURL) else { + throw Abort(.internalServerError, reason: "Unable to convert String '\(self.accessTokenURL)' to URL") + } + request.http.method = .POST + request.http.url = url + return try request.make(Client.self).send(request) + }.flatMap(to: String.self) { response in + return response.content.get(String.self, at: ["access_token"]) + } + } + + /// The route that the OAuth provider calls when the user has benn authenticated. + /// + /// - Parameter request: The request from the OAuth provider. + /// - Returns: A response that should redirect the user back to the app. + /// - Throws: Any errors that occur in the implementation code. + public func callback(_ request: Request) throws -> EventLoopFuture { + + return try self.fetchToken(from: request).flatMap(to: ResponseEncodable.self) { accessToken in + + guard let domain = request.query[String.self, at: "shop"] else { throw Abort(.badRequest) } + + let session = try request.session() + session.setAccessToken(accessToken) + session.setShopDomain(domain) + session.setNonce(nil) + + return try self.callbackCompletion(request, accessToken) + }.flatMap(to: Response.self) { response in + return try response.encode(for: request) + } + } + + /// Creates the authentication URL + /// + /// - Parameter request: the request from the browser to initiate authorization + /// - Returns: fully formed URL that should be used to redirect back to Shopify + /// - Throws: Any errors that occur in the implementation code. + public func generateAuthenticationURL(request: Request) throws -> URL { + let nonce = String(UUID().uuidString.prefix(6)) + try request.session().setNonce(nonce) + return try authURLFrom(request, nonce: nonce) + } + + private func authURLFrom(_ request: Request, nonce: String) throws -> URL { + guard let shop = request.query[String.self, at: "shop"] else { throw Abort(.badRequest) } + + return URL(string: "https://\(shop)/admin/oauth/authorize?" + "client_id=\(tokens.clientID)&" + + "scope=\(scope.joined(separator: ","))&" + + "redirect_uri=\(callbackURL)&" + + "state=\(nonce)")! + } + + private func accessTokenURLFrom(_ request: Request) throws -> String { + guard let shop = request.query[String.self, at: "shop"] else { throw Abort(.badRequest) } + return "https://\(shop)/admin/oauth/access_token" + } }