diff --git a/Sources/Imperial/Services/Microsoft/Microsoft.swift b/Sources/Imperial/Services/Microsoft/Microsoft.swift new file mode 100644 index 0000000..2279eaa --- /dev/null +++ b/Sources/Imperial/Services/Microsoft/Microsoft.swift @@ -0,0 +1,24 @@ +import Vapor + +public class Microsoft: FederatedService { + public var tokens: FederatedServiceTokens + public var router: FederatedServiceRouter + + @discardableResult + public required init( + router: Router, + authenticate: String, + authenticateCallback: ((Request)throws -> (Future))?, + callback: String, + scope: [String] = [], + completion: @escaping (Request, String)throws -> (Future) + ) throws { + self.router = try MicrosoftRouter(callback: callback, completion: completion) + self.tokens = self.router.tokens + + self.router.scope = scope + try self.router.configureRoutes(withAuthURL: authenticate, authenticateCallback: authenticateCallback, on: router) + + OAuthService.register(.microsoft) + } +} diff --git a/Sources/Imperial/Services/Microsoft/MicrosoftAuth.swift b/Sources/Imperial/Services/Microsoft/MicrosoftAuth.swift new file mode 100644 index 0000000..45d41b0 --- /dev/null +++ b/Sources/Imperial/Services/Microsoft/MicrosoftAuth.swift @@ -0,0 +1,16 @@ +import Vapor + +public class MicrosoftAuth: FederatedServiceTokens { + public static var idEnvKey: String = "MICROSOFT_CLIENT_ID" + public static var secretEnvKey: String = "MICROSOFT_CLIENT_SECRET" + public var clientID: String + public var clientSecret: String + + public required init() throws { + let idError = ImperialError.missingEnvVar(MicrosoftAuth.idEnvKey) + let secretError = ImperialError.missingEnvVar(MicrosoftAuth.secretEnvKey) + + self.clientID = try Environment.get(MicrosoftAuth.idEnvKey).value(or: idError) + self.clientSecret = try Environment.get(MicrosoftAuth.secretEnvKey).value(or: secretError) + } +} diff --git a/Sources/Imperial/Services/Microsoft/MicrosoftCallbackBody.swift b/Sources/Imperial/Services/Microsoft/MicrosoftCallbackBody.swift new file mode 100644 index 0000000..4b6ec69 --- /dev/null +++ b/Sources/Imperial/Services/Microsoft/MicrosoftCallbackBody.swift @@ -0,0 +1,21 @@ +import Vapor + +struct MicrosoftCallbackBody: Content { + let code: String + let clientId: String + let clientSecret: String + let redirectURI: String + let scope: String + let grantType: String = "authorization_code" + + static var defaultContentType: MediaType = .urlEncodedForm + + enum CodingKeys: String, CodingKey { + case code = "code" + case clientId = "client_id" + case clientSecret = "client_secret" + case redirectURI = "redirect_uri" + case grantType = "grant_type" + case scope = "scope" + } +} diff --git a/Sources/Imperial/Services/Microsoft/MicrosoftRouter.swift b/Sources/Imperial/Services/Microsoft/MicrosoftRouter.swift new file mode 100644 index 0000000..04426c6 --- /dev/null +++ b/Sources/Imperial/Services/Microsoft/MicrosoftRouter.swift @@ -0,0 +1,78 @@ +import Vapor +import Foundation + +public class MicrosoftRouter: FederatedServiceRouter { + public let tokens: FederatedServiceTokens + public let callbackCompletion: (Request, String)throws -> (Future) + public var scope: [String] = [] + public let callbackURL: String + public let accessTokenURL: String = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + + public required init( + callback: String, + completion: @escaping (Request, String) throws -> (Future) + ) throws { + self.tokens = try MicrosoftAuth() + self.callbackURL = callback + self.callbackCompletion = completion + } + + public func authURL(_ request: Request) throws -> String { + return "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?" + + "client_id=\(self.tokens.clientID)&" + + "response_type=code&" + + "redirect_uri=\(self.callbackURL)&" + + "response_mode=query&" + + "scope=\(scope.joined(separator: "%20"))&" + + "prompt=consent" + } + + public func fetchToken(from request: Request)throws -> Future { + let code: String + + if let queryCode: String = try request.query.get(at: "code") { + code = queryCode + } else if let error: String = try request.query.get(at: "error_description") { + throw Abort(.badRequest, reason: error) + } else { + throw Abort(.badRequest, reason: "Missing 'code' key in URL query") + } + + let body = MicrosoftCallbackBody( + code: code, + clientId: self.tokens.clientID, + clientSecret: self.tokens.clientSecret, + redirectURI: self.callbackURL, + scope: scope.joined(separator: "%20") + ) + + 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"]) + } + } + + public func callback(_ request: Request)throws -> Future { + return try self.fetchToken(from: request).flatMap(to: ResponseEncodable.self) { accessToken in + let session = try request.session() + + session.setAccessToken(accessToken) + try session.set("access_token_service", to: OAuthService.microsoft) + + return try self.callbackCompletion(request, accessToken) + }.flatMap(to: Response.self) { response in + return try response.encode(for: request) + } + } +} diff --git a/Sources/Imperial/Services/Microsoft/Service+Microsoft.swift b/Sources/Imperial/Services/Microsoft/Service+Microsoft.swift new file mode 100644 index 0000000..9341dee --- /dev/null +++ b/Sources/Imperial/Services/Microsoft/Service+Microsoft.swift @@ -0,0 +1,6 @@ +extension OAuthService { + public static let microsoft = OAuthService.init( + name: "microsoft", + endpoints: [:] + ) +} diff --git a/docs/Microsoft/README.md b/docs/Microsoft/README.md new file mode 100644 index 0000000..575b1bc --- /dev/null +++ b/docs/Microsoft/README.md @@ -0,0 +1,107 @@ +# Federated Login with Microsoft + +We need to start by registering an app in [Azure Active Directory admin center][1] as described in [this tutorial][2], creating a client ID and secret so Microsoft can identify us. Make sure to save the Client ID and Client secret. + +Now that we have the necessary information for Microsoft, we will setup Imperial with our application. + +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.14.0") +``` + +**Note:** There might be a later version of the package available, in which case you will want to use that version. + +You will also need to add the package as a dependency for the targets you will be using it in: + +```swift +.target(name: "App", dependencies: ["Vapor", "Imperial"], + exclude: [ + "Config", + "Database", + "Public", + "Resources" + ] +), +``` + +Then run `vapor update` or `swift package update`. Make sure you regenerate your Xcode project afterwards if you are using Xcode. + +Now that Imperial is installed, we need to add `SessionMiddleware` to our middleware configuration: + +```swift +public func configure( + _ config: inout Config, + _ env: inout Environment, + _ services: inout Services +) throws { + //... + + // Register middleware + var middlewares = MiddlewareConfig() // Create _empty_ middleware config + // Other Middleware... + middlewares.use(SessionsMiddleware.self) + services.register(middlewares) + + //... +} + +``` + +Now, when you run your app and you are using `FluentSQLite`, you will probably get the following error: + +``` +⚠️ [ServiceError.ambiguity: Please choose which KeyedCache you prefer, multiple are available: MemoryKeyedCache, FluentCache.] [Suggested fixes: `config.prefer(MemoryKeyedCache.self, for: KeyedCache.self)`. `config.prefer(FluentCache.self, for: KeyedCache.self)`.] +``` + +Just pick one of the listed suggestions and place it at the top of your `configure` function. If you want your data to persist across server reboots, use `config.prefer(FluentCache.self, for: KeyedCache.self)` + +Imperial uses environment variables to access the client ID and secret to authenticate with Microsoft. To allow Imperial to access these tokens, you will create these variables, called `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET`, with the client ID and secret assigned to them. Imperial can then access these vars and use there values to authenticate with Microsoft. + +Now, all we need to do is register the Microsoft service in your main router method, like this: + +```swift +try router.oAuth(from: Microsoft.self, authenticate: "microsoft", callback: "http://localhost:8080/microsoft-complete") { (request, token) in + print(token) + return Future(request.redirect(to: "/")) +} +``` + +If you just want to redirect, without doing anything else in the callback, you can use the helper `Route.oAuth` method that takes in a redirect string: + +```swift +try router.oAuth(from: Microsoft.self, authenticate: "microsoft", callback: "http://localhost:8080/microsoft-complete", redirect: "/") +``` + +The `callback` argument is the path you will go to when you want to authenticate the user. The `callback` argument has to be the same path that you entered as `Redirect URI`when you registered your application on Microsoft Azure Active Directory admin center: + +![The callback path for Microsoft OAuth](https://github.com/vapor-community/Imperial/blob/master/docs/Microsoft/callback-uri.png?raw=true) + +The completion handler is fired when the callback route is called by the OAuth provider. The access token is passed in and a response is returned. + +If you ever want to get the `access_token` in a route, you can use a helper method for the `Request` type that comes with Imperial: + +```swift +let token = try request.accessToken() +``` + +Now that you are authenticating the user, you will want to protect certain routes to make sure the user is authenticated. You can do this by adding the `ImperialMiddleware` to a router group (or maybe your middleware config): + +```swift +let protected = router.grouped(ImperialMiddleware()) +``` + +Then, add your protected routes to the `protected` group: + +```swift +protected.get("me", handler: me) +``` + +The `ImperialMiddleware` by default passes the errors it finds onto `ErrorMiddleware` where they are caught, but you can initialize it with a redirect path to go to if the user is not authenticated: + +```swift +let protected = router.grouped(ImperialMiddleware(redirect: "/")) +``` + +[1]: https://aad.portal.azure.com/ +[2]: https://docs.microsoft.com/en-us/graph/tutorials/php?tutorial-step=2 diff --git a/docs/Microsoft/callback-uri.png b/docs/Microsoft/callback-uri.png new file mode 100644 index 0000000..35f5a74 Binary files /dev/null and b/docs/Microsoft/callback-uri.png differ