diff --git a/Sources/Imperial/Services/Auth0/Auth0.swift b/Sources/Imperial/Services/Auth0/Auth0.swift new file mode 100644 index 0000000..9594211 --- /dev/null +++ b/Sources/Imperial/Services/Auth0/Auth0.swift @@ -0,0 +1,24 @@ +import Vapor + +public class Auth0: 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 Auth0Router(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(.auth0) + } +} diff --git a/Sources/Imperial/Services/Auth0/Auth0Auth.swift b/Sources/Imperial/Services/Auth0/Auth0Auth.swift new file mode 100644 index 0000000..f6dc0c6 --- /dev/null +++ b/Sources/Imperial/Services/Auth0/Auth0Auth.swift @@ -0,0 +1,20 @@ +import Vapor + +public class Auth0Auth: FederatedServiceTokens { + public static var domain: String = "AUTH0_DOMAIN" + public static var idEnvKey: String = "AUTH0_CLIENT_ID" + public static var secretEnvKey: String = "AUTH0_CLIENT_SECRET" + public var domain: String + public var clientID: String + public var clientSecret: String + + public required init() throws { + let domainError = ImperialError.missingEnvVar(Auth0Auth.domain) + let idError = ImperialError.missingEnvVar(Auth0Auth.idEnvKey) + let secretError = ImperialError.missingEnvVar(Auth0Auth.secretEnvKey) + + self.domain = try Environment.get(Auth0Auth.domain).value(or: domainError) + self.clientID = try Environment.get(Auth0Auth.idEnvKey).value(or: idError) + self.clientSecret = try Environment.get(Auth0Auth.secretEnvKey).value(or: secretError) + } +} diff --git a/Sources/Imperial/Services/Auth0/Auth0CallbackBody.swift b/Sources/Imperial/Services/Auth0/Auth0CallbackBody.swift new file mode 100644 index 0000000..616b243 --- /dev/null +++ b/Sources/Imperial/Services/Auth0/Auth0CallbackBody.swift @@ -0,0 +1,19 @@ +import Vapor + +struct Auth0CallbackBody: Content { + let clientId: String + let clientSecret: String + let code: String + let redirectURI: String + let grantType: String = "authorization_code" + + static var defaultContentType: MediaType = .urlEncodedForm + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case clientSecret = "client_secret" + case code = "code" + case redirectURI = "redirect_uri" + case grantType = "grant_type" + } +} diff --git a/Sources/Imperial/Services/Auth0/Auth0Router.swift b/Sources/Imperial/Services/Auth0/Auth0Router.swift new file mode 100644 index 0000000..a10eac9 --- /dev/null +++ b/Sources/Imperial/Services/Auth0/Auth0Router.swift @@ -0,0 +1,86 @@ +import Vapor +import Foundation + +public class Auth0Router: FederatedServiceRouter { + public let baseURL: String + public let tokens: FederatedServiceTokens + public let callbackCompletion: (Request, String)throws -> (Future) + public var scope: [String] = [ ] + public var requiredScopes = [ "openid" ] + public let callbackURL: String + public let accessTokenURL: String + + private func providerUrl(path: String) -> String { + return self.baseURL.finished(with: "/") + path + } + + public required init(callback: String, completion: @escaping (Request, String)throws -> (Future)) throws { + let auth = try Auth0Auth() + self.tokens = auth + self.baseURL = "https://\(auth.domain)" + self.accessTokenURL = baseURL.finished(with: "/") + "oauth/token" + self.callbackURL = callback + self.callbackCompletion = completion + } + + public func authURL(_ request: Request) throws -> String { + let path="authorize" + + var params=[ + "response_type=code", + "client_id=\(self.tokens.clientID)", + "redirect_uri=\(self.callbackURL)", + ] + + let allScopes = self.scope + self.requiredScopes + let scopeString = allScopes.joined(separator: " ").addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + if let scopes = scopeString { + params += [ "scope=\(scopes)" ] + } + + let rtn = self.providerUrl(path: path + "?" + params.joined(separator: "&")) + return rtn + } + + 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") { + throw Abort(.badRequest, reason: error) + } else { + throw Abort(.badRequest, reason: "Missing 'code' key in URL query") + } + + let body = Auth0CallbackBody(clientId: self.tokens.clientID, + clientSecret: self.tokens.clientSecret, + code: code, + redirectURI: self.callbackURL) + + 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 + request.http.contentType = .urlEncodedForm + + 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.auth0) + + return try self.callbackCompletion(request, accessToken) + }.flatMap(to: Response.self) { response in + return try response.encode(for: request) + } + } +} diff --git a/Sources/Imperial/Services/Auth0/Service+Auth0.swift b/Sources/Imperial/Services/Auth0/Service+Auth0.swift new file mode 100644 index 0000000..acabc24 --- /dev/null +++ b/Sources/Imperial/Services/Auth0/Service+Auth0.swift @@ -0,0 +1,6 @@ +extension OAuthService { + public static let auth0 = OAuthService.init( + name: "auth0", + endpoints: [:] + ) +} diff --git a/docs/Auth0/Applications-Marked.png b/docs/Auth0/Applications-Marked.png new file mode 100644 index 0000000..fe97e25 Binary files /dev/null and b/docs/Auth0/Applications-Marked.png differ diff --git a/docs/Auth0/README.md b/docs/Auth0/README.md new file mode 100644 index 0000000..22b47c8 --- /dev/null +++ b/docs/Auth0/README.md @@ -0,0 +1,247 @@ +# Federated Login with Auth0 + +We need to start by creating a regular web application so Auth0 can identify us. Go to the Applications menu from the side-bar on your Auth0 Dashboard. + +Select '+ Create Application'. Provide a name for your app and select 'Regular Web Applications'. Then select 'Create'. + +![Create Application Screenshot](Applications-Marked.png) + + +Go to the 'Settings' tab for your application to find your Domain, Client ID, and Client Secret. + +![Setting-1](SamplePortal-Settings-1.png) + +You'll want to configure this is a "Regular Web Application" using "POST" for the Token Endpoint Authentication Method. + +![Settings-2](SamplePortal-Settings-2.png) + + +Be sure to configure the proper settings for: + - Allowed Callback URLs + - Application Login URI + - Allowed Web Origins + - Allowed Logout URLs + +If testing on your local system, you can start with the following settings: + + - Allowed Callback URLs: + - http://localhost:8080/login/callback, https://localhost/login/callback, https://127.0.0.1/login/callback + - Application Login URI: + - https://127.0.0.1/login + - Allowed Web Origins: + - http://localhost:8080/, https://localhost/, https://127.0.0.1/ + - Allowed Logout URLs: + - http://localhost:8080/, https://localhost/, https://127.0.0.1/ + +![Settings-3](SamplePortal-Settings-3.png) + +![Settings-4](SamplePortal-Settings-4.png) + + +Now that we have the necessary information for Auth0, 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") +``` + +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"]), +``` + +If using Xcode 11, then updating the Packages.swift is sufficient. However, if +you're using the generated xcodeproj file, then run `vapor update` or `swift +package update` to regenerate the xcodeproj file. + +If not already configured, be sure 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 Google. To allow Imperial to access these tokens, you will create these variables, called `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID` and `AUTH0_CLIENT_SECRET`, with the domain, client ID, and secret assigned to them. Imperial can then access these vars and use there values to authenticate with Auth0. + +Now, all we need to do is register the Auth0 service in your main router method, like this: + + +```swift +try router.oAuth(from: Auth0.self, authenticate: "login", callback: "http://localhost/login/callback", scope: ["profile"]) { (request, token) in + print(token) + return Future(request.redirect(to: "/")) +} +``` + +The `profile` scope will allow you to get more profile information about the user via the resulting access token. Auth0 requires the `openid` scope, which will automatically be included for you. + +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: Auth0.self, authenticate: "login", callback: "http://localhost/login/callback", redirect: "/") +``` + +The `callback` argument is the path you will go to when you want to authenticate the user. The `callback` argument has to be one of the URLs you entered in the Allowed Callback URLs on your Auth0 dashboard. + +The completion handler is fired when the callback route is called by the OAuth provider (Auth0). 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: "/")) +``` + +## Supporting SSL + +Auth0 requires https for the Login URI. However, Vapor 3 does not natively +support SSL. To get by for local testing: + +Generate certificates for your localhost by: + + - Installing [mkcert](https://github.com/FiloSottile/mkcert) + - Run `mkcert -install` + - Run `mkcert localhost 127.0.0.1 ::1` + +Then create an nginx.conf file like so: + +``` +events { + worker_connections 512; ## Default: 1024 +} + +http { + server { + listen 443 ssl; + server_name localhost; + + ssl_certificate /path/to/localhost.pem; + ssl_certificate_key /path/to/localhost-key.pem; + #ssl_client_certificate /etc/ssl/certs/ca.crt; + #ssl_verify_client optional; + + location / { + proxy_pass http://localhost:8080/; + } + } +} +``` + +Then run nginx like so: + +```sh +# nginx -c $(pwd)/nginx.conf +``` + +nginx will now be running and listening to https (port 443) requests and +forwarding to your localhost port 8080, which your vapor app is listening on. + +You can stop nginx by executing: + +```sh +# killall nginx +``` + +## Authenticated Routes + +You can require authenticated routes by adding these lines to your `routes()`: + +```swift + let protected = router.grouped(ImperialMiddleware()) + + protected.get("members_only") { req -> Future in + return try req.view().render("hello") + } +``` + +This will ensure that the /members_only path is accessible to authenticated users. If an unauthenticated user hits this route, they will get a 401 error with a message like the following: + +```text + {"error":true,"reason":"User currently not authenticated"} +``` + +## Authenticated + Unauthenticated Routes + +To allow a route to support both authenticated and unauthenticated users, you can add these lines to your `routes()`: + +```swift + router.get { req -> Future in + let view: String + do { + guard try req.accessToken() != "" else { + throw Abort(.unauthorized, reason: "User currently not authenticated") + } + view = "welcome-auth" + } catch let error as Abort where error.status == .unauthorized { + view = "welcome" + } + return try req.view().render(view) + } +``` + +This creates a route for "/". If the access token is available (and not empty), it will present the "welcome-auth" view. And if not available, it will present the "welcome" view. + +## Logout + +To support logout, you can create a route like so: + +```swift + let auth0 = try Auth0Auth() + router.get("/logout") { req -> Response in + let return_url = "https://localhost/" + let logout_url = "https://\(auth0.domain)/v2/logout?client_id=\(auth0.clientID)&returnTo=\(return_url)" + try req.destroySession() + return req.redirect(to: logout_url) + } +``` + +This route must exist on an unauthenticated route. This is because the destroySession call will eliminate the session. If you attempt to do this on a protected route, you will get an "unauthorized" error rather than the redirect. + +### References + +Some potentially useful references: + +* [Auth0 Login Using Authorization Code Flow](https://auth0.com/docs/flows/guides/auth-code/add-login-auth-code) +* [Auth0 Troubleshooting Guide](https://auth0.com/docs/troubleshoot/guides/check-login-logout-issues) +* [nginx full example configuration](https://www.nginx.com/resources/wiki/start/topics/examples/full/) diff --git a/docs/Auth0/SamplePortal-Settings-1.png b/docs/Auth0/SamplePortal-Settings-1.png new file mode 100644 index 0000000..9073cf8 Binary files /dev/null and b/docs/Auth0/SamplePortal-Settings-1.png differ diff --git a/docs/Auth0/SamplePortal-Settings-2.png b/docs/Auth0/SamplePortal-Settings-2.png new file mode 100644 index 0000000..f83a298 Binary files /dev/null and b/docs/Auth0/SamplePortal-Settings-2.png differ diff --git a/docs/Auth0/SamplePortal-Settings-3.png b/docs/Auth0/SamplePortal-Settings-3.png new file mode 100644 index 0000000..be61249 Binary files /dev/null and b/docs/Auth0/SamplePortal-Settings-3.png differ diff --git a/docs/Auth0/SamplePortal-Settings-4.png b/docs/Auth0/SamplePortal-Settings-4.png new file mode 100644 index 0000000..ba27534 Binary files /dev/null and b/docs/Auth0/SamplePortal-Settings-4.png differ