24
Sign in with Apple Authentication
Written by Tim Condon
Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as
text.You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.
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.