Chapters

Hide chapters

Server-Side Swift with Vapor

Third Edition · iOS 13 · Swift 5.2 - Vapor 4 Framework · Xcode 11.4

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: Creating a Simple Web API

Section 1: 13 chapters
Show chapters Hide chapters

24. Sign in with Apple Authentication
Written by Tim Condon

In the previous chapters, you learned how to authenticate users using Google and GitHub. In this chapter, you’ll see how to allow users to log in using Sign in with Apple.

Sign in with Apple

Apple introduced Sign in with Apple in 2019 as a privacy-centric way of authenticating users in your apps. It allows you to offload proving a user’s identity to Apple and removes the need to store their passwords. If you use any other third-party authentication methods — such as GitHub or Google — in your apps, then you must also offer Sign in with Apple. Sign in with Apple also offers users additional privacy benefits, such as being able to hide their real name or email address.

Note: To complete this chapter, you’ll need a paid Apple developer account to set up the required identifiers and profiles.

Sign in with Apple on iOS

Here’s how authenticating users with Sign in with Apple works on iOS:

  1. The iOS app uses ASAuthorizationAppleIDButton to show the button and start the sign in flow.
  2. When the user completes the Sign in with Apple process, iOS returns an ASAuthorizationAppleIDCredential to your app. This contains a JSON Web Token (JWT).
  3. The app sends the JWT to the server — the TIL Vapor app in this case. The server then validates the token.
  4. If the user is new, the server creates a user account.
  5. The server then signs the user in. You’ll return a Token to the iOS app to complete the sign-in flow.

JWT

JSON Web Tokens, or JWTs, are a way of transmitting information between different parties. Since they contain JSON, you can send any information you want in them. The issuer of the JWT signs the token with a private key or secret. The JWT contains a signature and header. Using these two pieces, you can verify the integrity of the token. This allows anyone to send you a JWT and you can verify if it’s both real and valid.

For Sign in with Apple, the server receives the token and then gets Apple’s public key from its server to validate the token. Vapor contains helper functions to make this simple.

Sign in with Apple on the web

Sign in with Apple works in a similar way on websites. Apple provide a JavaScript library you integrate to render the button. The button works across platforms. On macOS in Safari, it interacts with the browser directly. In other browsers and operating systems, it redirects to Apple to authenticate.

When a user successfully authenticates, Apple redirects to a URL on your website in a similar way to GitHub and Google. The redirect contains the JWT, which you can then send to your server and validate as before.

Integrating Sign in with Apple on iOS

Open the TILApp project in Xcode and open Package.swift. Replace:

.package(
  url: "https://github.com/vapor-community/Imperial.git",
  from: "1.0.0")

with the following:

.package(
  url: "https://github.com/vapor-community/Imperial.git",
  from: "1.0.0"),
.package(
  url: "https://github.com/vapor/jwt.git",
  from: "4.0.0")

This adds Vapor’s JWT library as a dependency. Next, replace:

.product(name: "ImperialGitHub", package: "Imperial")

with the following:

.product(name: "ImperialGitHub", package: "Imperial"),
.product(name: "JWT", package: "jwt")

This adds the JWT library as a dependency to your App target. Next, open User.swift and add the following property below var acronyms: [Acronym]:

@OptionalField(key: "siwaIdentifier")
var siwaIdentifier: String?

This adds a new field to User to store the identifier returned by Sign in with Apple. This allows you to identify users across devices and sessions. Note the use of @OptionalField because the property is optional. You must use @OptionalField with any optional properties. Otherwise, you may encounter issues when saving and retrieving models from the database. Next, replace the initializer to account for the new property with the following:

init(
  id: UUID? = nil,
  name: String,
  username: String,
  password: String,
  siwaIdentifier: String? = nil
) {
  self.name = name
  self.username = username
  self.password = password
  self.siwaIdentifier = siwaIdentifier
}

Using a default property means you don’t need to update any code. Open CreateUser.swift and add the following:

.field("siwaIdentifier", .string)

below .field("password", .string, .required) to account for the new property:

Since the field is optional, you don’t need to mark it with .required. Next, open UsersController.swift. At the top of the file, below import Vapor, import the new dependency:

import JWT
import Fluent

You need Fluent, as well, for querying the database. Next, at the bottom of the file, create a new type for the data you need to sign in with Apple:

struct SignInWithAppleToken: Content {
  let token: String
  let name: String?
}

The type contains the JWT from iOS as well as an optional name for you to use when registering. Next, create a new route below loginHandler(_:) for signing in with Apple:

func signInWithApple(_ req: Request) 
  throws -> EventLoopFuture<Token> {
    // 1
    let data = try req.content.decode(SignInWithAppleToken.self)
    // 2
    guard let appIdentifier = 
      Environment.get("IOS_APPLICATION_IDENTIFIER") else {
      throw Abort(.internalServerError)
    }
    // 3
    return req.jwt
      .apple
      .verify(data.token, applicationIdentifier: appIdentifier)
      .flatMap { siwaToken -> EventLoopFuture<Token> in
        // 4
        User.query(on: req.db)
          .filter(\.$siwaIdentifier == siwaToken.subject.value)
          .first()
          .flatMap { user in
            let userFuture: EventLoopFuture<User>
            if let user = user {
              userFuture = req.eventLoop.future(user)
            } else {
              // 5
              guard
                let email = siwaToken.email,
                let name = data.name
              else {
                return req.eventLoop
                  .future(error: Abort(.badRequest))
              }
              let user = User(
                name: name, 
                username: email, 
                password: UUID().uuidString, 
                siwaIdentifier: siwaToken.subject.value)
              userFuture = user.save(on: req.db).map { user }
            }
            // 6
            return userFuture.flatMap { user in
              let token: Token
              do {
                // 7
                token = try Token.generate(for: user)
              } catch {
                return req.eventLoop.future(error: error)
              }
              // 8
              return token.save(on: req.db).map { token }
            }
        }
    }
}

Here’s what the new method does:

  1. Decode the request body to the SignInWithAppleToken type created earlier.
  2. Get the application identifier from the environment variables. If it doesn’t exist, throw an internal server error.
  3. Use Vapor’s helper method to verify the JWT with Apple. This gets Apple’s public key to check the signature and payload.
  4. Search the database for an existing user with the Sign in with Apple identifier.
  5. If there’s no existing user, get the email from the token and name from the request body. Create a new User, using a dummy password, and save it in the database.
  6. Resolve the user future. This is either the user returned from the database or the recently saved user. This allows you to write the code for generating a token once.
  7. Generate a token for the user.
  8. Save the token and return it as a response.

Finally, register the route in boot(routes:) below usersRoute.get(":userID", "acronyms", use: getAcronymsHandler):

usersRoute.post("siwa", use: signInWithApple)

This routes a POST request to /api/users/siwa to signInWithApple(_:). Build the app to make sure everything works.

Setting up the iOS app

Open the iOS app in Xcode and navigate to the TILiOS target. Click + Capability and select Sign in with Apple. Next, open LoginTableViewController.swift. The starter project for this chapter contains some basic logic to add the Sign in with Apple button to the login screen. The button triggers handleSignInWithApple() when pressed.

To start, make LoginTableViewController conform to the necessary protocols. At the bottom of the file add the following extension:

extension LoginTableViewController: 
  ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(
      for controller: ASAuthorizationController
    ) -> ASPresentationAnchor {
      guard let window = view.window else {
        fatalError("No window found in view")
      }
      return window
    }
}

This conforms LoginTableViewController to ASAuthorizationControllerPresentationContextProviding to provide a window to present the sign in dialog on. Next, at the bottom of the file, add the following extension:

// 1
extension LoginTableViewController: 
  ASAuthorizationControllerDelegate {
    // 2
    func authorizationController(
      controller: ASAuthorizationController, 
      didCompleteWithAuthorization 
        authorization: ASAuthorization
    ) {
    }

    // 3
    func authorizationController(
      controller: ASAuthorizationController, 
      didCompleteWithError error: Error
    ) {
      print("Error signing in with Apple - \(error)")
    }
}

Here’s what the extension does:

  1. Conforms LoginTableViewController to ASAuthorizationControllerDelegate. This handles success and failure cases for signing in with Apple.
  2. Implement authorizationController(controller:didCompleteWithAuthorization:) as required by the protocol. The app calls this when the device authenticates the user.
  3. Implement authorizationController(controller:didCompleteWithError:) to handle the case when signing in with Apple fails. For now, just print the error to the console.

Next, add the following implementation to handleSignInWithApple():

// 1
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
// 2
let authorizationController = 
  ASAuthorizationController(authorizationRequests: [request])
// 3
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
// 4
authorizationController.performRequests()

Here’s what the new code does:

  1. Create an ASAuthorizationAppleIDRequest with the scopes for a user’s full name and email.
  2. Create an ASAuthorizationController with the request created in step 1.
  3. Set the delegate and presentationContextProvider to the current instance of LoginViewController.
  4. Start the Sign in with Apple request.

Next, in authorizationController(controller:didCompleteWithAuthorization:) add the following:

// 1
if let credential = authorization.credential 
  as? ASAuthorizationAppleIDCredential {
  // 2
  guard 
    let identityToken = credential.identityToken,
    let tokenString = String(
      data: identityToken, 
      encoding: .utf8) 
  else {
    print("Failed to get token from credential")
    return
  }
  // 3
  let name: String?
  if let nameProvided = credential.fullName {
    let firstName = nameProvided.givenName ?? ""
    let lastName = nameProvided.familyName ?? ""
    name = "\(firstName) \(lastName)"
  } else {
    name = nil
  }
  // 4
  let requestData = 
    SignInWithAppleToken(token: tokenString, name: name)
  do {
    // 5
    try Auth().login(
      signInWithAppleInformation: requestData
    ) { result in
      switch result {
      // 6
      case .success:
        DispatchQueue.main.async {
          let appDelegate = 
            UIApplication.shared.delegate as? AppDelegate
          appDelegate?.window?.rootViewController =
            UIStoryboard(name: "Main", bundle: Bundle.main)
              .instantiateInitialViewController()
        }
      // 7
      case .failure:
        let message = "Could not Sign in with Apple."
        ErrorPresenter.showError(message: message, on: self)
      }
    }
  // 8
  } catch {
    let message = "Could not login - \(error)"
    ErrorPresenter.showError(message: message, on: self)
  }
}

Here’s what’s going on:

  1. Try to cast the credential to an ASAuthorizationAppleIDCredential. You may handle other credential types so don’t return an error if the cast fails.
  2. Get the identity token and convert it to a string to send to the API.
  3. Get the name from the credentials. You won’t receive the name if the user has already signed in with Apple for the app.
  4. Create SignInWithAppleToken to send to the server.
  5. Use login(signInWithAppleInformation:completion:) to send the JWT to the server and get a token back.
  6. If the login succeeds, change the root view controller to the main screen as before.
  7. If log in fails, show an error message.
  8. Catch any decoding errors thrown and show an error message.

That’s everything you need to do to implement Sign in with Apple!

Finally, in the Project navigator, select the TILiOS target and open Signing & Capabilities. Select your development team and choose a unique bundle identifier:

Important: Sign in with Apple does not work reliably on the simulator, so the steps below require you to run the app on an iOS device.

In TILApp, open .env and add the following at the bottom of the file:

IOS_APPLICATION_IDENTIFIER=<YOUR_BUNDLE_ID>

Then, in Terminal, reset the database to accommodate the new field on User:

docker rm -f postgres
docker run --name postgres \
  -e POSTGRES_DB=vapor_database \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5432:5432 -d postgres

Build and run the Vapor app. When the firewall asks if you want to accept external connections, click Allow.

The Vapor starter project allows external connections. You set app.http.server.configuration.hostname = "0.0.0.0" in configure.swift. This allows connections from any IP address.

Finally, in TILiOS, open ResourceRequest.swift and replace let apiHostname = "http://localhost:8080" to use the IP address of your machine. E.g.

let apiHostname = "http://192.168.1.70:8080"

Build and run the app on your device. You’ll see the Sign in with Apple button on the log in screen:

Tap Sign in with Apple. You’ll see the Sign in with Apple sheet appear:

Tap Share My Email and then Continue or Continue with Password. Enter your password or allow Face ID to complete and the app logs you in!

Note: Which option you see in the final step above is a function of which device you’re testing on.

Pro Tip: If you need to reset the state of Sign in with Apple for an app you’re testing, see https://support.apple.com/en-us/HT210426 for instructions.

Integrating Sign in with Apple on the web

Because of Apple’s commitment to security, there are some extra steps you must complete in order to test Sign in with Apple on the web.

Setting up ngrok

Sign in with Apple on the web only works with HTTPS connections, and Apple will only redirect to an HTTPS address. This is fine for deploying, but makes testing locally harder. ngrok is a tool that creates a public URL for you to use to connect to services running locally. In your browser, visit https://ngrok.com and download the client and create an account.

Note: You can also install ngrok with Homebrew.

Next, head to https://dashboard.ngrok.com/ and get your auth token. Then, in Terminal, type:

/Applications/ngrok authtoken <YOUR_TOKEN>

This sets up the client with your account. Then, in Terminal, enter:

/Applications/ngrok http 8080

This creates an HTTP tunnel to your Vapor app. You’ll see the URL listed in Terminal:

If you visit this URL in your browser, you’ll see your TIL website!

Setting up the web app

Sign in with Apple on the web requires you to configure a service ID with Apple. Go to https://developer.apple.com/account/ and click Certificates, Identifiers & Profiles. Click Identifiers and click + to create a new identifier. Under the identifier type, choose Services ID and click Continue:

Enter a description and then choose a unique identifier for your website, similar to the bundle identifier for the app. Click Continue and then Register:

Click your new identifier to configure it. Click the checkbox next to Sign In with Apple and click Configure:

Under Primary App ID, select the application identifier for the TILiOS app. Under Domains and Subdomains, add the domain of your ngrok listener, e.g. bede0108405c.ngrok.io. Then, under Return URLs, add https://<YOUR_NGROK_DOMAIN>/login/siwa/callback. This is the URL Apple will redirect to when Sign in with Apple is complete:

Click Next and then Done. Back on the Edit your Services ID Configuration page, click Continue and then Save.

Setting up Vapor

Return to the Vapor TILApp project in Xcode and open WebsiteController.swift. At the bottom of the file, add the following:

struct AppleAuthorizationResponse: Decodable {
  struct User: Decodable {
    struct Name: Decodable {
      let firstName: String?
      let lastName: String?
    }
    let email: String
    let name: Name?
  }

  let code: String
  let state: String
  let idToken: String
  let user: User?

  enum CodingKeys: String, CodingKey {
    case code
    case state
    case idToken = "id_token"
    case user
  }

  init(from decoder: Decoder) throws {
    let values = try decoder.container(keyedBy: CodingKeys.self)
    code = try values.decode(String.self, forKey: .code)
    state = try values.decode(String.self, forKey: .state)
    idToken = 
      try values.decode(String.self, forKey: .idToken)

    if let jsonString = 
      try values.decodeIfPresent(String.self, forKey: .user),
       let jsonData = jsonString.data(using: .utf8) {
      self.user = 
        try JSONDecoder().decode(User.self, from: jsonData)
    } else {
      user = nil
    }
  }
}

This Decodable type matches the response sent by Apple in the callback. It contains some optional data for the user, the JWT and a state property. Next, at the bottom of the file, create a new context to pass to Leaf after the callback:

struct SIWAHandleContext: Encodable {
  let token: String
  let email: String?
  let firstName: String?
  let lastName: String?
}

This contains the data from AppleAuthorizationResponse in a simpler format for Leaf to use. Next, create a new route handler below registerPostHandler(_:) for handling the redirect from Apple:

func appleAuthCallbackHandler(_ req: Request) 
  throws -> EventLoopFuture<View> {
    // 1
    let siwaData = 
      try req.content.decode(AppleAuthorizationResponse.self)
    // 2
    guard
      let sessionState = req.cookies["SIWA_STATE"]?.string, 
      !sessionState.isEmpty, 
      sessionState == siwaData.state 
    else {
      req.logger
        .warning("SIWA does not exist or does not match")
      throw Abort(.unauthorized)
    }
    // 3
    let context = SIWAHandleContext(
      token: siwaData.idToken, 
      email: siwaData.user?.email, 
      firstName: siwaData.user?.name?.firstName, 
      lastName: siwaData.user?.name?.lastName)
    // 4
    return req.view.render("siwaHandler", context)
}
  1. Decode the request body to AppleAuthorizationResponse.
  2. Get the session state from a cookie named SIWA_STATE. Ensure it matches the state from AppleAuthorizationResponse. If it doesn’t match, return a 401 Unauthorized error.
  3. Create the context for Leaf.
  4. Render the siwaHandler template using the provided context.

Register the route in boot(routes:) below authSessionsRoutes.post("register", use: registerPostHandler) add the following:

authSessionsRoutes.post(
  "login", 
  "siwa", 
  "callback", 
  use: appleAuthCallbackHandler)

This routes a POST request to /login/siwa/callback — the URL you registered with Apple — to appleAuthCallbackHandler(_:).

Create a new file in Resources/Views called siwaHandler.leaf for the Leaf template. Open the new file and insert the following:

<!-- 1 -->
<!doctype html>
<html lang="en" class="h-100">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" 
     content="width=device-width, initial-scale=1">
    <title>Sign In With Apple</title>
    <!-- 2 -->
    <script>
      // 3
      function handleCallback() {
        // 4
        const form = document.getElementById("siwaRedirectForm")
        // 5
        form.style.display = 'none';
        // 6
        form.submit();
      }
      // 7
      window.onload = handleCallback;
    </script>
  </head>
  <body class="d-flex flex-column h-100">
    <!-- 8 -->
    <form action="/login/siwa/handle" method="POST" 
     id="siwaRedirectForm">
      <!-- 9 -->
      <input type="hidden" name="token" value="#(token)">
      <input type="hidden" name="email" value="#(email)">
      <input type="hidden" name="firstName" 
       value="#(firstName)">
      <input type="hidden" name="lastName" 
       value="#(lastName)">
      <!-- 10 -->
      <input type="submit" 
       value="If nothing happens click here">
    </form>
  </body>
</html>

This file doesn’t use base.leaf like the other files since the user won’t see the content. Here’s what the template does:

  1. Create a basic HTML 5 page for the redirect.
  2. Embed some JavaScript code into the page.
  3. Define a JavaScript function handleCallback().
  4. Get the form from the page using the identifier siwaRedirectForm.
  5. Set the display for the form to none — this hides the form so it’s not visible.
  6. Submit the form automatically.
  7. When the page loads, trigger handleCallback().
  8. Define a form the sends a POST request to /login/siwa/handle. Set the form ID to siwaRedirectForm so the JavaScript code can find it.
  9. Add a number of hidden fields that contain the data from the callback.
  10. Add a submit button. This allows users to manually submit the form if the JavaScript fails to load.

You may be wondering - why bother with the redirect? After all, this code redirects to /login/siwa/handle. That’s where you then need to register or log in the user? Why not do this here?

Modern browsers use a flag in cookies called SameSite. A browser will not send cookies to the server on a POST request from a different domain unless you set the cookie’s SameSite flag to none. This means that you can’t access any session data from the callback handler as the request came from Apple’s domain. You can’t log in a user without this. You workaround this by setting a special cookie on a special page that the browser will send to the server. You can then redirect to the real log in page. Since this redirect comes from the same domain, the browser will send the session cookie, allowing you to complete log in.

In Xcode, at the bottom of WebsiteController.swift, add a new type to represent the data sent by the new form:

struct SIWARedirectData: Content {
  let token: String
  let email: String?
  let firstName: String?
  let lastName: String?
}

Then, at the top of the file below import Vapor, add:

import Fluent

This allows you to use Fluent’s queries. Next, create a new route handler below appleAuthCallbackHandler(_:) for the redirect:

func appleAuthRedirectHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {
    // 1
    let data = try req.content.decode(SIWARedirectData.self)
    // 2
    guard let appIdentifier = 
      Environment.get("WEBSITE_APPLICATION_IDENTIFIER") else {
      throw Abort(.internalServerError)
    }
    return req.jwt
      .apple
      .verify(data.token, applicationIdentifier: appIdentifier)
      .flatMap { siwaToken in
        User.query(on: req.db)
          .filter(\.$siwaIdentifier == siwaToken.subject.value)
          .first()
          .flatMap { user in
            let userFuture: EventLoopFuture<User>
            if let user = user {
              userFuture = req.eventLoop.future(user)
            } else {
              // 3
              guard
                let email = data.email, 
                let firstName = data.firstName, 
                let lastName = data.lastName 
              else {
                return req.eventLoop
                  .future(error: Abort(.badRequest))
              }
              // 4
              let user = User(
                name: "\(firstName) \(lastName)", 
                username: email, 
                password: UUID().uuidString, 
                siwaIdentifier: siwaToken.subject.value)
              userFuture = user.save(on: req.db).map { user }
            }
            // 5
            return userFuture.map { user in
              // 6
              req.auth.login(user)
              // 7
              return req.redirect(to: "/")
            }
        }
    }
}

This method is similar to signInWithApple(_:) for the iOS app. The differences are:

  1. Decode the request body to SIWARedirectData.

  2. Get the application identifier from the environment variables. This is a different application identifier from the iOS app.

  3. The request body contains the user’s first name and last name as separate components. Ensure the request data contains both components for a new user.

  4. Create a new User from the request data. Combine firstName and lastName to create the name.

  5. Get the resolved user from the future. This uses map(_:) instead of flatMap(_:) since the closure returns a non-future.

  6. Log the user in to the website for future requests.

  7. Redirect to the homepage.

Register the new route in boot(routes:) under authSessionsRoutes.post("login", "siwa", "callback", use: appleAuthCallbackHandler):

authSessionsRoutes.post(
  "login", 
  "siwa", 
  "handle", 
  use: appleAuthRedirectHandler)

This routes a POST request to /login/siwa/handler — the URL the form redirects to — to appleAuthRedirectHandler(_:).

Finally, you need to display the Sign in with Apple button on the log in and register pages. At the bottom of the file, add a new type for the data required for Sign in with Apple:

struct SIWAContext: Encodable {
  let clientID: String
  let scopes: String
  let redirectURI: String
  let state: String
}

This has the required properties for creating the Sign in with Apple button. Next, replace LoginContext with the following:

struct LoginContext: Encodable {
  let title = "Log In"
  let loginError: Bool
  let siwaContext: SIWAContext
  
  init(loginError: Bool = false, siwaContext: SIWAContext) {
    self.loginError = loginError
    self.siwaContext = siwaContext
  }
}

This adds the new context to LoginContext. Next, below appleAuthRedirectHandler(_:), add a new method to create SIWAContext:

private func buildSIWAContext(on req: Request) 
  throws -> SIWAContext {
  // 1
  let state = [UInt8].random(count: 32).base64
  // 2
  let scopes = "name email"
  // 3
  guard let clientID = 
    Environment.get("WEBSITE_APPLICATION_IDENTIFIER") else {
      req.logger.error("WEBSITE_APPLICATION_IDENTIFIER not set")
      throw Abort(.internalServerError)
  }
  // 4
  guard let redirectURI = 
    Environment.get("SIWA_REDIRECT_URL") else {
      req.logger.error("SIWA_REDIRECT_URL not set")
      throw Abort(.internalServerError)
  }
  // 5
  let siwa = SIWAContext(
    clientID: clientID, 
    scopes: scopes, 
    redirectURI: redirectURI, 
    state: state)
  return siwa
}

Here’s what the new function does:

  1. Create a random state, similar to creating a new token value.
  2. Define the scopes required for your app. You need both the name and email.
  3. Get the client ID from the environment variables, otherwise throw a 500 Internal Server Error. This is the same as your website application identifier.
  4. Get the redirect URL from the environment variables, otherwise throw a 500 Internal Server Error.
  5. Create SIWAContext and return it.

Next, change the return type of loginHandler(_:) to:

func loginHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {

You need to convert View to Response in order to set the special cookie. You also need to throw errors with the new code. Next, replace the body of loginHandler(_:) with the following:

let context: LoginContext
// 1
let siwaContext = try buildSIWAContext(on: req)
if let error = req.query[Bool.self, at: "error"], error {
  context = LoginContext(
    loginError: true, 
    siwaContext: siwaContext)
} else {
  context = LoginContext(siwaContext: siwaContext)
}
// 2
return req.view
  .render("login", context)
  .encodeResponse(for: req)
  .map { response in
    // 3
    let expiryDate = Date().addingTimeInterval(300)
    // 4
    let cookie = HTTPCookies.Value(
      string: siwaContext.state, 
      expires: expiryDate, 
      maxAge: 300, 
      isHTTPOnly: true, 
      sameSite: HTTPCookies.SameSitePolicy.none)
    // 5
    response.cookies["SIWA_STATE"] = cookie
    // 6
    return response
}

Here’s what the new code does:

  1. Build SIWAContext from the request and pass it to LoginContext.
  2. Convert the EventLoopFuture<View> to an EventLoopFuture<Response> using encodeResponse(for:).
  3. Create an expiry date of 5 minutes into the future.
  4. Create a new cookie with the state created in buildSIWAContext(on:). Note that sameSite is set to .none so the server sends the cookie during the redirect.
  5. Set the cookie in the response using SIWA_STATE as the name. This is the same name you look for in appleAuthCallbackHandler(_:).
  6. Return the response.

Next, change the signature of loginPostHandler(_:) to allow you to throw errors:

func loginPostHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {

Next, replace the else block in loginPostHandler(_:) with the following:

let siwaContext = try buildSIWAContext(on: req)
let context = LoginContext(
  loginError: true, 
  siwaContext: siwaContext)
return req.view
  .render("login", context)
  .encodeResponse(for: req)
  .map { response in
    let expiryDate = Date().addingTimeInterval(300)
    let cookie = HTTPCookies.Value(
      string: siwaContext.state, 
      expires: expiryDate, 
      maxAge: 300, 
      isHTTPOnly: true, 
      sameSite: HTTPCookies.SameSitePolicy.none)
    response.cookies["SIWA_STATE"] = cookie
    return response
}

This is the same code as used in loginHandler(_:). It encodes the necessary properties in LoginContext and sets the cookie if log in fails.

Next, replace RegisterContext with the following:

struct RegisterContext: Encodable {
  let title = "Register"
  let message: String?
  let siwaContext: SIWAContext

  init(message: String? = nil, siwaContext: SIWAContext) {
    self.message = message
    self.siwaContext = siwaContext
  }
}

This adds SIWAContext as a property in RegisterContext so you can display the Sign in with Apple button on the register page. Next, replace the signature of registerHandler(_:) with the following:

func registerHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {

This changes the return type to EventLoopFuture<Response> so you can set the cookie and throw errors. Next, replace the body of registerHandler(_:) with:

let siwaContext = try buildSIWAContext(on: req)
let context: RegisterContext
if let message = req.query[String.self, at: "message"] {
  context = RegisterContext(
    message: message, 
    siwaContext: siwaContext)
} else {
  context = RegisterContext(siwaContext: siwaContext)
}
return req.view
  .render("register", context)
  .encodeResponse(for: req)
  .map { response in
    let expiryDate = Date().addingTimeInterval(300)
    let cookie = HTTPCookies.Value(
      string: siwaContext.state, 
      expires: expiryDate, 
      maxAge: 300, 
      isHTTPOnly: true, 
      sameSite: HTTPCookies.SameSitePolicy.none)
    response.cookies["SIWA_STATE"] = cookie
    return response
}

The changes are identical to the changes made in loginHandler(_:). They ensure you pass everything you need to Leaf to show the Sign in with Apple button on the register page.

Open Resources/Views/login.leaf. At the bottom of the content block, above #endexport, add the following:

<!-- 1 -->
<div id="appleid-signin" class="signin-button" 
 data-color="black" data-border="true" 
 data-type="sign in"></div>
<!-- 2 -->
<script type="text/javascript" 
 src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
<!-- 3 -->
<script type="text/javascript">
  AppleID.auth.init({
    clientId : '#(siwaContext.clientID)',
    scope : '#(siwaContext.scopes)',
    redirectURI : '#(siwaContext.redirectURI)',
    state : '#(siwaContext.state)',
    usePopup : false
  });
</script>

Here’s what the new code does:

  1. Define a <div> to contain the Sign in with Apple button.
  2. Import the Sign in with Apple JavaScript file from Apple. This handles all the logic for you on the web page.
  3. Initialize AppleID.auth to create a Sign in with Apple button. This uses the values from SIWAContext.

Next, open Resources/Views/register.leaf and add the same code below </form>:

<!-- 1 -->
<div id="appleid-signin" class="signin-button" 
 data-color="black" data-border="true" 
 data-type="sign in"></div>
<!-- 2 -->
<script type="text/javascript" 
 src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
<!-- 3 -->
<script type="text/javascript">
  AppleID.auth.init({
    clientId : '#(siwaContext.clientID)',
    scope : '#(siwaContext.scopes)',
    redirectURI : '#(siwaContext.redirectURI)',
    state : '#(siwaContext.state)',
    usePopup : false
  });
</script>

Finally, open Public/styles/style.css and add the following to the bottom of the file:

#appleid-signin {
  width: 240px;
  height: 40px;
  margin-top: 10px;
}
#appleid-signin:hover {
  cursor: pointer;
}
#appleid-signin > div {
  outline: none;
}

This adds some styling to the button to make it look nice on the page.

Open .env in a text editor and add the following variables at the end of the file:

WEBSITE_APPLICATION_IDENTIFIER=<YOUR_WEBSITE_IDENTIFIER>
SIWA_REDIRECT_URL=https://<YOUR_NGROK_DOMAIN>/login/siwa/callback

These match the values you provided when you created the service ID in Apple’s developer portal. Build and run the app and go to https://<YOUR_NGROK_DOMAIN>. Click Register and you’ll see the new Sign in with Apple button!

Note: You must use the ngrok URL instead of localhost, otherwise the redirect won’t work correctly.

The button also appears on the log in page. Click the Sign in with Apple button. On Safari, the browser will prompt you to enter your system password — the one you use to log in on your Mac — to authorize Sign in with Apple:

On Chrome, the app will redirect you to sign in to your Apple ID on Apple’s website to sign in:

Complete the log in process for your chosen browser and the app will log you in with your Apple ID!

Where to go from here?

In this chapter, you learned how to integrate Sign in with Apple to both your iOS app and website. This complements first-party and external sign in experiences. It allows your users to choose a range of options for authentication.

In the next chapter, you’ll learn how to integrate with a third party email provider. You’ll use another community package and learn how to send emails. To demonstrate this, you’ll implement a password reset flow into your application in case users forget their password.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.