Home iOS & Swift Books Server-Side Swift with Vapor

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:

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.

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.

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")
.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")
.product(name: "ImperialGitHub", package: "Imperial")
.product(name: "ImperialGitHub", package: "Imperial"),
.product(name: "JWT", package: "jwt")
@OptionalField(key: "siwaIdentifier")
var siwaIdentifier: String?
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
}
.field("siwaIdentifier", .string)
import JWT
import Fluent
struct SignInWithAppleToken: Content {
  let token: String
  let name: String?
}
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 }
            }
        }
    }
}
usersRoute.post("siwa", use: signInWithApple)

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.

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

IOS_APPLICATION_IDENTIFIER=<YOUR_BUNDLE_ID>
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
let apiHostname = "http://192.168.1.70:8080"

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.

/Applications/ngrok authtoken <YOUR_TOKEN>
/Applications/ngrok http 8080

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:

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
    }
  }
}
struct SIWAHandleContext: Encodable {
  let token: String
  let email: String?
  let firstName: String?
  let lastName: String?
}
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)
}
authSessionsRoutes.post(
  "login", 
  "siwa", 
  "callback", 
  use: appleAuthCallbackHandler)
<!-- 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>
struct SIWARedirectData: Content {
  let token: String
  let email: String?
  let firstName: String?
  let lastName: String?
}
import Fluent
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: "/")
            }
        }
    }
}
authSessionsRoutes.post(
  "login", 
  "siwa", 
  "handle", 
  use: appleAuthRedirectHandler)
struct SIWAContext: Encodable {
  let clientID: String
  let scopes: String
  let redirectURI: String
  let state: String
}
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
  }
}
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
}
func loginHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {
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
}
func loginPostHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {
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
}
struct RegisterContext: Encodable {
  let title = "Register"
  let message: String?
  let siwaContext: SIWAContext

  init(message: String? = nil, siwaContext: SIWAContext) {
    self.message = message
    self.siwaContext = siwaContext
  }
}
func registerHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {
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
}
<!-- 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>
<!-- 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>
#appleid-signin {
  width: 240px;
  height: 40px;
  margin-top: 10px;
}
#appleid-signin:hover {
  cursor: pointer;
}
#appleid-signin > div {
  outline: none;
}
WEBSITE_APPLICATION_IDENTIFIER=<YOUR_WEBSITE_IDENTIFIER>
SIWA_REDIRECT_URL=https://<YOUR_NGROK_DOMAIN>/login/siwa/callback

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.

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.