Server-Side Vapor Swift Implementation With Google, Apple, and Github Authentication

15 min read

Learn how to build a server application using Vapor, a web framework built on top of Apple’s SwiftNIO.

Tim C.
Tim C.
Published May 23, 2022 Updated May 27, 2022
Vapor Swift OSS server-side project feature image

In this post, you'll learn how to build a server application with the Vapor framework to provide tokens to an iOS app to use with the Stream Chat SDK. You'll learn how to leverage different types of authentication for users and provide them with tokens.

Note: for this tutorial, you'll need Xcode 12.3 installed, Homebrew, and Docker.

The Vapor Backend

Vapor is a framework for writing HTTP server applications, backends, and APIs in Swift. It was first created in 2016 and is an open-source project driven by the community. It allows you to create complex, full-featured applications that can power mobile applications or stand on their own.

To start, you need to install the Vapor Toolbox. This is a command-line application that helps you create Vapor applications. In Terminal, type:

bash
1
brew install vapor

This will install the toolbox from Homebrew. Check if the installation succeeded by running:

bash
1
vapor --help

You should see something like:

Vapor help command

Next, run the following commands in Terminal:

bash
1
2
3
4
5
6
# 1 mkdir StreamVapor # 2 cd StreamVapor # 3 vapor new StreamVaporBackend --template https://github.com/GetStream/stream-chat-vapor-swift-demo.git

Here's what the commands do:

  1. Create a new directory in your current working directory to place the apps.
  2. Change your directory to the new directory you created.
  3. Create a new Vapor project called StreamVaporBackend. This uses the repository at https://github.com/GetStream/stream-chat-vapor-swift-demo.git as a template as it contains some of the basic building blocks for authentication already set up.

Finally, open the Vapor application in Xcode with the following commands:

bash
1
2
3
4
# 1 cd StreamVaporBackend # 2 open Package.swift

This navigates to the Vapor app project directory and opens the project in Xcode using Swift Package Manager. It may take a few minutes to resolve all the dependencies the first time.

Adding The Swift SDK

First, you need to add the Vapor Stream SDK which creates JSON Web Tokens (JWTs) that you provide to Stream’s iOS Chat SDK. Open Package.swift in Xcode. In the dependencies array, below .package(url: "https://github.com/vapor-community/Imperial.git", from: "1.0.0"), add the following:

swift
1
.package(name: "StreamChat", url: "https://github.com/GetStream/stream-chat-vapor-swift.git", from: "0.1.0"),

This adds the Stream Vapor SDK to your dependencies. Next, in the target's dependencies, add the following code below .product(name: "ImperialGoogle", package: "Imperial"),:

swift
1
.product(name: "StreamSDKVapor", package: "StreamChat"),

This links the Stream SDK to your Vapor app target. Save the file so Xcode resolves the new dependencies.

Integrating the SDK With Vapor

Open configure.swift. This is where you configure your Vapor applications and set up any services like your database. At the top of the file, add:

swift
1
import StreamSDKVapor

Then at the bottom of configure(_:), add this code:

swift
1
2
3
4
5
6
7
8
9
10
// 1 guard let streamAccessKey = Environment.get("STREAM_ACCESS_KEY"), let streamAccessSecret = Environment.get("STREAM_ACCESS_SECRET") else { app.logger.critical("STREAM keys not set") fatalError("STREAM keys not set") } // 2 let streamConfig = StreamConfiguration(accessKey: streamAccessKey, accessSecret: streamAccessSecret) app.stream.use(streamConfig)

Here's what this code does:

  1. Reads the Stream access key and secret from environment variables. If they can't be found, it throws a fatal error.
  2. Creates an instance of StreamConfiguration and configures Stream in the Vapor application to use it.

Now that you've configured the Stream SDK, you can use request.client in route handlers to access the Stream client. Open AuthController.swift to do this!

The AuthController contains the routes for authenticating and registering users in the Vapor application. The Vapor app currently supports authenticating with a username and password and sign-in with Apple, GitHub, and Google.

All authentication functions work in a similar way: They verify the user is who they say they are, either by checking the username and password the user supplied or by using a third-party service like Apple, GitHub, or Google. Once you've verified the user is who they say they are, you generate an authentication token for that user's session. A user uses the token to authenticate future requests with the Vapor app.

Currently, the Vapor application just returns an authentication token on login. However, the Stream SDK also needs a JWT to authenticate the SDK with Stream's servers. Change the authentication flow to provide this token as well.

First, open LoginResponse.swift and add a new property to LoginResponse:

swift
1
let streamToken: String

This represents the JWT the iOS app will use to sign requests to Stream. Next, back in AuthController, fix the error. Replace return LoginResponse(apiToken: token) in createLoginResponse(for:on:) with the following:

swift
1
2
3
4
// 1 let streamToken = try req.stream.createToken(id: user.username) // 2 return LoginResponse(apiToken: token, streamToken: streamToken.jwt)

Here's what the new code does:

  1. Generates a Stream Token with the user's username as the ID for Stream (this uses the Stream SDK you added as a dependency earlier).
  2. Returns the Stream JWT in LoginResponse alongside the API token.

This ensures that anyone authenticating with a username and password or Sign in with Apple receives both an API token and Stream token. In generateRedirect(on:for:) replace let redirectURL = "streamVapor://auth?token=\(token.value)" with:

swift
1
2
let streamToken = try req.stream.createToken(id: user.username) let redirectURL = "streamVapor://auth?token=\(token.value)&streamToken=\(streamToken.jwt)"

This generates a Stream Token for this authentication flow and passes it to the iOS app.

Setting Up Google Authentication

The template you used to create the Vapor project uses Imperial to handle OAuth 2.0 flows and already has the routes set up. If you want to enable Google sign-in, you need to provide the app with a callback URL and client details.

In your browser, go to https://console.developers.google.com/apis/credentials.

If this is the first time you’ve used Google’s credentials, the site prompts you to create a project. Click Create Project to create a project for the Vapor application. Fill in the form with an appropriate name, for example, Vapor Stream Backend:

Google Create a Project

After it creates the project, the site takes you back to the Google credentials page for the newly created project. This time, click Create Credentials to create credentials for the Vapor app and choose OAuth client ID:

Google credentials OAuth Client

Next, click Configure consent screen to set up the page Google presents to users so they can allow your application access to their details.

Choose External for the user type and click Create.

Google OAuth user type

Add an app name and select the user support email.

Google Consent Screen

At the bottom of the page, add your developer contact information. Click Save and Continue. On the next screen, you configure the scopes for your application. These are the permissions you want to request from users, such as their email address. Click Add or remove scopes and select both /auth/userinfo.email and /auth/userinfo.profile. This gives you access to the user’s email and profile which you need to create an account in the Vapor app.

Select scopes

Once you’ve selected the scopes, click Update and then Save and continue. Next, you need to select the users you’ll use for testing. Click Add Users and add any users you want to be able to log in. If you publish your app, you can verify your domain and app to remove this limitation. Click Save and continue.

Select test user

You’ve completed the OAuth consent screen so click Back to dashboard. Click the Credentials page again and click Create Credentials once more and choose OAuth client ID. When creating a client ID, choose Web application. Add a redirect URI for your application for testing — http://localhost:8080/oauth/google. This is the URL that Google redirects back to once users have allowed your application access to their data.

If you want to deploy your application to the internet, such as with AWS or Heroku, add another redirect for the URL for that site — for example, https://stream-vapor.herokuapp.com/oauth/google:

Google select web redirect URLs

Click Create and the site will give you your client ID and client secret:

Google client id secret

Note: You must keep these safe and secure. Your secret allows you access to Google’s APIs, and you should not share or check the secret into source control. You should treat it like a password.

Finally, back in Xcode, OPTION click the scheme to launch the scheme editor. In the Run target, select the Arguments tab and add three environment variables:

  • GOOGLE_CALLBACK_URL - http://localhost:8080/oauth/google
  • GOOGLE_CLIENT_ID -
  • GOOGLE_CLIENT_SECRET -
Google Xcode Client Credentials

You can now sign in with Google to the API.

Setting up GitHub Authentication

Ready to integrate? Our team is standing by to help you. Contact us today and launch tomorrow!

If you want to enable signing in with GitHub for your API, it follows a similar pattern to Google. In your browser, go to https://github.com/settings/developers. Click New OAuth App or Register a new application:

Fill in the form with an appropriate name, for example, Vapor Stream Demo. Set the Homepage URL to http://localhost:8080 for this application and provide a sensible description. Set the Authorization callback URL to http://localhost:8080/oauth/github. This is the URL that GitHub redirects back to once users have allowed your application access to their data:

Github create new application

Click Register application. After it creates the application, the site takes you back to the application’s information page. That page provides the client ID. Click Generate a new client secret to get a client secret:

Github OAuth client id secret

Note: As before, you must keep these safe and secure. Your secret allows you access to GitHub’s APIs and you should not share or check the secret into source control. You should treat it like a password.

Finally, back in Xcode, OPTION click the scheme to launch the scheme editor. In the Run target, select the Arguments tab and add three environment variables:

  • GITHUB_CALLBACK_URL - http://localhost:8080/oauth/github
  • GITHUB_CLIENT_ID -
  • GITHUB_CLIENT_SECRET -
Github Xcode Client Credentials

You can now sign in with GitHub to the API.

Setting up Sign in with Apple

Sign in with Apple (SiWA) works differently than GitHub and Google because SiWA uses JWTs instead of an OAuth 2.0 flow. For the Vapor application, you just need to set a single environment variable – IOS_APPLICATION_IDENTIFIER. In the Xcode scheme editor, set this to the bundle identifier you'll use for your iOS application. For example, io.getstream.swift.vapor-demo:

SiWA XCode Vapor Environment Variables

Finishing the Vapor App

Your Vapor application is now set up to provide Stream tokens to an iOS app, congratulations! Build the app to make sure everything compiles.

Next, you need to set up a database for the app to use. The project contains a script to do just that! Make sure you've launched the Docker app. Then, in Terminal, run:

bash
1
./scripts/localDockerDB.swift start

This starts a PostgreSQL database inside a Docker container for the Vapor app to connect to.

Finally, edit the scheme in Xcode to add your Stream keys. Create two environment variables for the Run scheme:

  • STREAM_ACCESS_KEY - The access key for your Stream application
  • STREAM_ACCESS_SECRET - The access secret for your Stream application

You can find these in your Stream dashboard.

Note: if you want to connect to the Vapor app from another machine, for example, a real iOS device, add the following at the bottom of the function in configure.swift:

swift
1
app.http.server.configuration.hostname = "0.0.0.0"

This allows connections to your Vapor app to all devices. Run the app to make sure everything works! You should see something like

[ NOTICE ] Server starting on http://0.0.0.0:8080

in the Xcode console.

The iOS App

With the Vapor application complete, you now need to build an iOS app to talk to it! First, clone the project at https://github.com/GetStream/stream-chat-vapor-swift-demo-ios/ and open it in Xcode. This contains a SwiftUI app with the Stream SDK partly integrated, as described in the docs.

The app contains a single main view that wraps ChatChannelListView from the Stream SDK in VaporDemoApp.swift. It also contains an AppDelegate to hold the StreamChat reference, as described in the docs.

However, the app contains an Auth type which is checked on app start-up to see if a user is logged in. If they are, then it proceeds as normal. If they're not then it displays the LoginView. You'll configure the view model for LoginView to talk to the Vapor app to get the Stream tokens once the user has logged in.

Note: the app logs the user out every time it loads to make it easy to test – you probably don't want to do this in a real app!

Handling Log In

Open LoginView.swift. The view contains an email and password form along with a Log In button, and then buttons for Google, GitHub, and Sign in with Apple. To start, implement the standard authentication flow.

When a user logs in and taps the Log In button, it runs the following code:

swift
1
2
3
let loginData = try await login() try await handleLoginComplete(loginData: loginData) auth.token = token

login() should take the email address and password and send it to the backend to authenticate. That will return an API key and Stream JWT you pass to handleLoginComplete(loginData:), which is used by all authentication methods. Finally, it sets the API token on the Auth service. This sets the isLoggedIn @Published value which reloads the main view. As the user is now marked as logged in they'll see the ChatChannelListView instead of the LoginView.

Open LoginViewModel.swift and replace the fatalError() in login() with the following:

swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 1 let path = "\(apiHostname)/auth/login" guard let url = URL(string: path) else { fatalError("Failed to convert URL") } // 2 guard let loginString = "\(username):\(password)".data(using: .utf8)?.base64EncodedString() else { fatalError("Failed to encode credentials") } // 3 var loginRequest = URLRequest(url: url) loginRequest.addValue("Basic \(loginString)", forHTTPHeaderField: "Authorization") loginRequest.httpMethod = "POST" do { // 4 let (data, response) = try await URLSession.shared.data(for: loginRequest) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw LoginError() } // 5 let loginResponse = try JSONDecoder().decode(LoginResponse.self, from: data) return LoginResultData(apiToken: loginResponse.apiToken.value, streamToken: loginResponse.streamToken) } catch { // 6 self.showingLoginErrorAlert = true throw error }

Here's what the new code does:

  1. Creates the URL using the API hostname passed in from the main view.
  2. Creates the HTTP Basic credentials for authentication.
  3. Creates a URLRequest using the URL in step 1. Set the HTTP Basic Credentials to the Authorization header.
  4. Sends the request to the Vapor app and ensures you get a 200 OK response.
  5. Decodes the response body to LoginResponse and converts that to LoginResultData to return.
  6. Catches any errors and sets showingLoginErrorAlert to display the error alert.

Handling Sign In With Apple

If you want to support Sign in With Apple, replace the body of handleSIWA(result:) with:

swift
1
2
3
4
5
6
7
8
switch result { case .failure(let error): self.showingLoginErrorAlert = true print("Error \(error)") throw error case .success(let authResult): }

This checks the result returned by iOS and, if there's a failure, shows the error alert, logs the error and rethrows it.

Next, inside the .success case insert:

swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 1 if let credential = authResult.credential as? ASAuthorizationAppleIDCredential { guard let identityToken = credential.identityToken, let tokenString = String(data: identityToken, encoding: .utf8) else { print("Failed to get token from credential") self.showingLoginErrorAlert = true throw LoginError() } // 2 let name: String? if let nameProvided = credential.fullName { name = "\(nameProvided.givenName ?? "") \(nameProvided.familyName ?? "")" } else { name = nil } // 3 let requestData = SignInWithAppleToken(token: tokenString, name: name, username: credential.email) let path = "\(apiHostname)/auth/siwa" guard let url = URL(string: path) else { fatalError("Failed to convert URL") } do { // 4 var loginRequest = URLRequest(url: url) loginRequest.httpMethod = "POST" loginRequest.httpBody = try JSONEncoder().encode(requestData) // 5 let (data, response) = try await URLSession.shared.data(for: loginRequest) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { self.showingLoginErrorAlert = true throw LoginError() } let loginResponse = try JSONDecoder().decode(LoginResponse.self, from: data) return LoginResultData(apiToken: loginResponse.apiToken.value, streamToken: loginResponse.streamToken) } catch { // 6 self.showingLoginErrorAlert = true throw error } } else { // 6 self.showingLoginErrorAlert = true throw LoginError() }

This code:

  1. Converts the credentials returned from the result to ASAuthorizationAppleIDCredential and ensures the identity token is provided. If not, it shows the error presenter.
  2. Gets the name from the credentials to pass to the API.
  3. Creates a SignInWithAppleToken to send to the API and creates the URL to send the request to.
  4. Creates a URLRequest using the URL created, sets the HTTP method to POST, and encodes SignInWithAppleToken to the request body.
  5. Sends the request to the API and ensures a 200 OK is returned; it shows the error alert if not. It also decodes the request body to LoginResponse and converts it to LoginResultData to return.
  6. Catches any errors to display the error alert.

Handling OAuth Log In

OAuth login is handled slightly differently because the app needs to use ASWebAuthenticationSession for the user to complete the OAuth flow. The app uses another view model to extract the required functions and bridges to a delegate to present the UI on top of the login view.

Open OAuthSignInViewModel.swift. signIn(with:) is called when the user taps the button to sign in with Google or GitHub. Replace the fatalError() with the following:

swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 1 let authURL: URL switch oauthType { case .google: guard let googleAuthURL = URL(string: "\(self.apiHostname)/iOS/login-google") else { fatalError("Failed to create URL") } authURL = googleAuthURL case .github: guard let githubAuthURL = URL(string: "\(self.apiHostname)/iOS/login-github") else { fatalError("Failed to create URL") } authURL = githubAuthURL } // 2 let scheme = "streamVapor" // 3 return try await withCheckedThrowingContinuation { continuation in // 4 let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: scheme) { callbackURL, error in // 5 guard error == nil, let callbackURL = callbackURL else { return continuation.resume(with: .failure(OAuthFailure())) } // 6 let queryItems = URLComponents(string: callbackURL.absoluteString)?.queryItems guard let token = queryItems?.first(where: { $0.name == "token" })?.value, let streamToken = queryItems?.first(where: { $0.name == "streamToken" })?.value else { return continuation.resume(with: .failure(OAuthFailure())) } // 7 let data = LoginResultData(apiToken: token, streamToken: streamToken) return continuation.resume(with: .success(data)) } // 8 session.presentationContextProvider = self session.start() }

The new code:

  1. Creates the correct URL to call the Vapor app with depending on if it's GitHub or Google.
  2. Sets the scheme to listen for (this is the same scheme you set in the redirect URL in the Vapor app).
  3. Wraps the closure-based code in withCheckedThrowingContinuation() to make it work with async/await.
  4. Creates a new ASWebAuthenticationSession to handle the log-in flow.
  5. Ensures a callback URL is provided; otherwise, it fails the continuation.
  6. Gets the API token and Stream JWT from the returned URL.
  7. Creates a new LoginResultData to return.
  8. Starts the ASWebAuthenticationSession and sets the presentation context to the view model.

Getting the Stream JWT

You've now implemented the log-in functions for standard authentication, Sign in with Apple and OAuth. All the functions retrieve a LoginResultData and pass it to handleLoginComplete(loginData:). This is the final piece of the puzzle. Here you need to get the authenticated user's data from the API and then connect the user with the Stream SDK.

Open LoginViewModel.swift again and replace the body of handleLoginComplete(loginData:) with:

swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
do { // 1 let path = "\(apiHostname)/account" guard let url = URL(string: path) else { fatalError("Failed to convert URL") } // 2 var loginRequest = URLRequest(url: url) loginRequest.addValue("Bearer \(loginData.apiToken)", forHTTPHeaderField: "Authorization") loginRequest.httpMethod = "GET" let (data, response) = try await URLSession.shared.data(for: loginRequest) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { self.showingLoginErrorAlert = true throw LoginError() } let userData = try JSONDecoder().decode(UserData.self, from: data) // 3 appDelegate.connectUser(token: loginData.streamToken, username: userData.username, name: userData.username) } catch { // 4 self.showingLoginErrorAlert = true throw error } return loginData.apiToken

Here's what the code does:

  1. Creates a URL to send the request to. This is the account URL provided in the Vapor app to get the authenticated user's data.
  2. Creates a URLRequest and sends it to the URL. Sets the HTTP method to GET and uses the API token as a Bearer token in the Authorization header. Ensures a 200 OK is returned; otherwise, it shows the error alert. Decodes the response body to UserData.
  3. Calls connectUser(token:username:name:) on the AppDelegate to connect the user to Stream. This uses the Stream JWT from logging in and the name and username from the user's details response. This calls connectUser(userInfo:token) on Stream's ChatClient [as described in the SwiftUI tutorial].
  4. Catches any errors and shows the error alert.
  5. Returns the API token so the view can tell the auth service.

Trying It Out

Build the app to make sure it all compiles! There are a few things to set to try it out.

First, in AppDelegate.swift, set the API key in chatClient to your API key. Then in VaporDemoApp.swift set the apiHostname. If you're only running in the simulator you can leave it as "http://localhost:8080". However if you want to run it on a real device you'll need to set this to http://<YOUR_MACHINES_IP_ADDRESS>:8080.

Build and run the app. You'll see the login screen:

iOS App Login

If you want to authenticate with a username and password, there's a Paw file in your Vapor app with a Register request. Alternatively, use curl to register a user:

bash
1
2
3
4
5
6
7
8
curl -X "POST" "http://localhost:8080/auth/register" \ -H 'Content-Type: application/json; charset=utf-8' \ -d $'{ "email": "tim@brokenhands.io", "password": "password", "username": "timc", "name": "Tim" }'

Use appropriate details for your user. Then pass the email and password to the iOS app and tap Log In. You'll see the Chat Screen:

Chat screen

Alternatively use Sign in With Apple, Google, or GitHub and the Vapor app will handle registration for those use cases.

You can find all the code at https://github.com/GetStream/stream-chat-vapor-swift-demo for the Vapor app and https://github.com/GetStream/stream-chat-vapor-swift-demo-ios/ for the iOS app. Both repositories have a final branch if you want to see the finished code.

Happy Chatting!

Ready to Increase App Engagement?
Integrate Stream’s real-time communication components today and watch your engagement rate grow overnight!
Contact Us Today!