Skip to content

Commit

Permalink
Merge pull request #25 from davidmuzi/shopify-cleanup
Browse files Browse the repository at this point in the history
Cleanup session code, docs fixes for Shopify
  • Loading branch information
calebkleveter authored Mar 18, 2019
2 parents 1dd41ee + 531ec8c commit 8320bef
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 151 deletions.
14 changes: 13 additions & 1 deletion Sources/Imperial/Helpers/Sessions+Imperial.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,28 @@ extension Request {

extension Session {

/// Keys used to store and retrieve items from the session
enum Keys {
static let token = "access_token"
}

/// Gets the access token from the session.
///
/// - Returns: The access token stored with the `access_token` key.
/// - Throws: `Abort.unauthorized` if no access token exists.m
public func accessToken()throws -> String {
guard let token = self["access_token"] else {
guard let token = self[Keys.token] else {
throw Abort(.unauthorized, reason: "User currently not authenticated")
}
return token
}

/// Sets the access token on the session.
///
/// - Parameter token: the access token to store on the session
public func setAccessToken(_ token: String) {
self[Keys.token] = token
}

/// Gets an object stored in a session with JSON as a given type.
///
Expand Down
2 changes: 1 addition & 1 deletion Sources/Imperial/Services/GitHub/GitHubRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public class GitHubRouter: FederatedServiceRouter {
return try self.fetchToken(from: request).flatMap(to: ResponseEncodable.self) { accessToken in
let session = try request.session()

session["access_token"] = accessToken
session.setAccessToken(accessToken)
try session.set("access_token_service", to: OAuthService.github)

return try self.callbackCompletion(request, accessToken)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Imperial/Services/Google/GoogleRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public class GoogleRouter: FederatedServiceRouter {
return try self.fetchToken(from: request).flatMap(to: ResponseEncodable.self) { accessToken in
let session = try request.session()

session["access_token"] = accessToken
session.setAccessToken(accessToken)
try session.set("access_token_service", to: OAuthService.google)

return try self.callbackCompletion(request, accessToken)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Imperial/Services/GoogleJWT/GoogleJWTRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public final class GoogleJWTRouter: FederatedServiceRouter {
return try self.fetchToken(from: request).flatMap(to: ResponseEncodable.self) { token in
let session = try request.session()

session["access_token"] = token
session.setAccessToken(token)
try session.set("access_token_service", to: OAuthService.googleJWT)

return try self.callbackCompletion(request, token)
Expand Down
40 changes: 22 additions & 18 deletions Sources/Imperial/Services/Shopify/Session+Shopify.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import Vapor

extension Session.Keys {
static let domain = "shop_domain"
static let nonce = "nonce"
}

extension Session {

enum SessionKeys {
static let domain = "shop_domain"
static let token = "access_token"
}

func shopDomain() throws -> String {
guard let domain = self[SessionKeys.domain] else { throw Abort(.notFound) }
return domain
}

func setShopDomain(domain: String) {
self[SessionKeys.domain] = domain
}

func setAccessToken(token: String) {
self[SessionKeys.token] = token
}

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]
}
}
40 changes: 22 additions & 18 deletions Sources/Imperial/Services/Shopify/Shopify.swift
Original file line number Diff line number Diff line change
@@ -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<ResponseEncodable>)) 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<ResponseEncodable>)) throws {

self.shopifyRouter = try ShopifyRouter(callback: callback, completion: completion)
self.shopifyRouter.scope = scope

try self.router.configureRoutes(withAuthURL: authenticate, on: router)
}
}
219 changes: 115 additions & 104 deletions Sources/Imperial/Services/Shopify/ShopifyRouter.swift
Original file line number Diff line number Diff line change
@@ -1,108 +1,119 @@
import Vapor

public class ShopifyRouter: FederatedServiceRouter {

public let tokens: FederatedServiceTokens
public let callbackCompletion: (Request, String) throws -> (Future<ResponseEncodable>)
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<ResponseEncodable>)) 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<Response> {

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<String> {

// 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<Response> {

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(token: accessToken)
session.setShopDomain(domain: 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<ResponseEncodable>)
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<ResponseEncodable>)) 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<Response> {

_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<String> {

// 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) { req in
guard let url = URL(string: self.accessTokenURL) else {
throw Abort(.internalServerError, reason: "Unable to convert String '\(self.accessTokenURL)' to URL")
}
req.http.method = .POST
req.http.url = url
return try request.make(Client.self).send(req)
}.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<Response> {

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"
}
}
15 changes: 8 additions & 7 deletions docs/Shopify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Now that we have the necessary information for Shopify, we will setup Imperial w
Add the following line of code to your `dependencies` array in your package manifest file:

```swift
.package(url: "https://github.com/vapor-community/Imperial.git", from: "0.7.0")
.package(url: "https://github.com/vapor-community/Imperial.git", from: "0.8.0")
```

**Note:** There might be a later version of the package available, in which case you will want to use that version.
Expand Down Expand Up @@ -64,12 +64,13 @@ Imperial uses environment variables to access the client ID and secret to authen
Now, all we need to do is register the Shopify service in your main router method, like this:

```swift
try router.oAuth(from: Shopify.self,
authenticate: "login-shopify",
callback: http://localhost:8080/auth,
scope: ["read_products", "read_orders"],
redirect: "/")
}
import Imperial

try router.oAuth(from: Shopify.self,
authenticate: "login-shopify",
callback: "http://localhost:8080/auth",
scope: ["read_products", "read_orders"],
redirect: "/")
```

The `callback` argument is the path you will go to when you want to authenticate the shop. The `callback` argument has to be the same path that you entered as a *Whitelisted redirection URL* on the app in the Partner Dashboard:
Expand Down

0 comments on commit 8320bef

Please sign in to comment.