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:
1brew install vapor
This will install the toolbox from Homebrew. Check if the installation succeeded by running:
1vapor --help
You should see something like:
Next, run the following commands in Terminal:
123456# 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:
- Create a new directory in your current working directory to place the apps.
- Change your directory to the new directory you created.
- 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:
1234# 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:
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"),
:
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:
1import StreamSDKVapor
Then at the bottom of configure(_:)
, add this code:
12345678910// 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:
- Reads the Stream access key and secret from environment variables. If they can't be found, it throws a fatal error.
- 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
:
1let 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:
1234// 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:
- 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).
- 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:
12let 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:
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:
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.
Add an app name and select the user support email.
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.
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.
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:
Click Create and the site will give you your client ID and client 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
-
You can now sign in with Google to the API.
Setting up GitHub Authentication
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:
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:
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
-
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
:
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:
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 applicationSTREAM_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:
1app.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:
123let 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:
1234567891011121314151617181920212223242526272829// 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:
- Creates the URL using the API hostname passed in from the main view.
- Creates the HTTP Basic credentials for authentication.
- Creates a
URLRequest
using the URL in step 1. Set the HTTP Basic Credentials to theAuthorization
header. - Sends the request to the Vapor app and ensures you get a 200 OK response.
- Decodes the response body to
LoginResponse
and converts that toLoginResultData
to return. - 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:
12345678switch 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:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647// 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:
- Converts the credentials returned from the result to
ASAuthorizationAppleIDCredential
and ensures the identity token is provided. If not, it shows the error presenter. - Gets the name from the credentials to pass to the API.
- Creates a
SignInWithAppleToken
to send to the API and creates the URL to send the request to. - Creates a
URLRequest
using the URL created, sets the HTTP method to POST, and encodesSignInWithAppleToken
to the request body. - 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 toLoginResultData
to return. - 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:
1234567891011121314151617181920212223242526272829303132333435363738394041// 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:
- Creates the correct URL to call the Vapor app with depending on if it's GitHub or Google.
- Sets the scheme to listen for (this is the same scheme you set in the redirect URL in the Vapor app).
- Wraps the closure-based code in
withCheckedThrowingContinuation()
to make it work withasync
/await
. - Creates a new
ASWebAuthenticationSession
to handle the log-in flow. - Ensures a callback URL is provided; otherwise, it fails the continuation.
- Gets the API token and Stream JWT from the returned URL.
- Creates a new
LoginResultData
to return. - 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:
12345678910111213141516171819202122232425do { // 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:
- 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. - 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 theAuthorization
header. Ensures a200 OK
is returned; otherwise, it shows the error alert. Decodes the response body toUserData
. - Calls
connectUser(token:username:name:)
on theAppDelegate
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 callsconnectUser(userInfo:token)
on Stream'sChatClient
[as described in the SwiftUI tutorial]. - Catches any errors and shows the error alert.
- 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:
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:
12345678curl -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:
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!