Dimitri James Tsiflitzis
CocoaHeads SKG #41
A random email address is created, and your personal email isn't shared with the app or website developer during the account setup.
<unique-string>@privaterelay.appleid.com
This unique relay address can only be used for communication from the specific app or website developer you created the account with. It can't be reused for other apps or services. (You set this up in the developer portal)
Add Capabilities:
Add a sign in button - SwiftUI:
/*
*/
import SwiftUI
import AuthenticationServices
class SignInWithApple: UIViewRepresentable {
// No customisation, so we return the view directly
func makeUIView(context: Context) -> ASAuthorizationAppleIDButton {
ASAuthorizationAppleIDButton()
}
// No state change, so do nothing
func updateUIView(_ uiView: ASAuthorizationAppleIDButton,
context: Context) {
}
}
Add a sign in button - UIKit:
/*
*/
import UIKit
func addAppleSignInButton() {
let button = ASAuthorizationAppleIDButton()
button.center = view.center
view.addSubview(button)
}
Button customizations:
/*
*/
import UIKit
func addAppleSignInButton() {
let button = ASAuthorizationAppleIDButton()
button.center = view.center
view.addSubview(button)
button.addTarget(self, action: #selector(handleAuthorizationAppleIDButtonPress),
for: .touchUpInside)
}
Add an action the usual way - addTarget:
/*
*/
import UIKit
@objc func handleAuthorizationAppleIDButtonPress() {
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
authorizationController.performRequests()
}
/*
*/
import UIKit
// MARK: - ASAuthorizationControllerPresentationContextProviding
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
guard
let window = self.window
else {
return nil
}
return window
}
We need to specify from where we will present the UI though.
// MARK: - ASAuthorizationControllerDelegate
func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
guard let appleIDToken = appleIDCredential.identityToken else {
print("Unable to fetch identity token")
return
}
guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else {
print("Unable to serialize token string from data: \(appleIDToken.debugDescription)")
return
}
let userIdentifier = appleIDCredential.user
let fullName = appleIDCredential.fullName
let email = appleIDCredential.email
...
}
}
ASAuthorizationControllerDelegate has two methods that are called.
One for success:
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
// Handle error.
guard let error = error as? ASAuthorizationError else {
return
}
switch error.code {
case .canceled: print("Canceled")
case .unknown: print("Unknown")
case .invalidResponse: print("Invalid Respone")
case .notHandled: print("Not handled")
case .failed: print("Failed")
@unknown default: print("Default")
}
}
One for failure:
No Apple ID
No 2FA Enabled
No iCloud Enabled
No Problem
First time:
All subsequent times:
You get the works
"Apple id"
Identity Token
Apple staff says:
Reset like it's the first time:
Settings >
Apple ID >
Password & Security >
Apple ID Logins/App Using Your Apple ID >
Your app name >
Stop using Apple ID
One of the fields Apple gives us at "Sign in", is the identity token, which is a JWT (JSON Web Token).
The identity token lets us verify that that user string came from Apple, not a malicious third party. We verify by decoding and looking at the identity token's subject field and comparing that against the user string.
We can trust the identity token from Apple because the JWT is signed.
For instance, if can decode this token at jwt.io
Let's have a closer look
If we pass along both the identity token and the user string to our server, it can verify that the credentials are in fact valid and from Apple.
However, the token expires very soon after its generation.
This means that we can only verify their identity at that point in time.
Which is good enough in order to transition them into our own sytem.
Our Node.js back-end will be responsible for two things:
So before we use the token (idenityToken), we need to make sure that it was signed by Apple's private key.
To do that, we need Apple's public key to verify the signature portion of the JWT.
You can get the public key from the following endpoint:
GET https://appleid.apple.com/auth/keys
{
"keys": [
{
"kty": "RSA",
"kid": "AIDOPK1",
"use": "sig",
"alg": "RS256",
"n": "lxrwmuYSAsTfn-lUu4goZSXBD9ackM9OJuwUVQHmbZo6GW4
Fu_auUdN5zI7Y1dEDfgt7m7QXWbHuMD01HLnD4eRtY-RNwCWdjNfE
aY_esUPY3OVMrNDI15Ns13xspWS3q-13kdGv9jHI28P87RvMpjz_J
CpQ5IM44oSyRnYtVJO-320SB8E2Bw92pmrenbp67KRUzTEVfGU4-o
bP5RZ09OxvCr1io4KJvEOjDJuuoClF66AT72WymtoMdwzUmhINjR0
XSqK6H0MdWsjw7ysyd_JhmqX5CAaT9Pgi0J8lU_pcl215oANqjy7O
b-VMhug9eGyxAWVfu_1u6QJKePlE-w",
"e": "AQAB"
}
]
}
The response would look like this:
The response would look like this:
This JSON is a JSON Web Key Set (JWKS)
{
"keys": [
{
"kty": "RSA",
"kid": "AIDOPK1",
"use": "sig",
"alg": "RS256",
"n": "lxrwmuYSAsTfn...u6QJKePlE-w",
"e": "AQAB"
}
]
}
It is a set of keys containing the public keys that use to verify JWT. In this case, it is a set of one key (Apple might add a new one in the future, so don't hard code it).
Property name | Description |
---|---|
alg | The specific cryptographic algorithm used with the key. |
kty | The family of cryptographic algorithms used with the key. |
use | How the key was meant to be used; sig represents the signature. |
x5c | The x.509 certificate chain. The first entry in the array is the certificate to use for token verification; the other certificates can be used to verify this first certificate. |
n | The modulus for the RSA public key. |
e | The exponent for the RSA public key. |
kid | The unique identifier for the key. |
x5t | The thumbprint of the x.509 cert (SHA-1 thumbprint). |
import * as jwksClient from 'jwks-rsa';
export const APPLE_BASE_URL = 'https://appleid.apple.com';
export const JWKS_APPLE_URI = '/auth/keys';
export const getApplePublicKey = async (kid) => {
const client = jwksClient({
cache: true,
jwksUri: `${APPLE_BASE_URL}${JWKS_APPLE_URI}`,
});
const key: any = await new Promise((resolve, reject) => {
client.getSigningKey(kid, (error, result) => {
if (error) {
return reject(error);
}
return resolve(result);
});
});
return key.publicKey as jwksClient.CertSigningKey ||
key.rsaPublicKey as jwksClient.RsaSigningKey;
};
As a JSON web key set
import * as jwt from 'jsonwebtoken';
export const APPLE_BASE_URL = 'https://appleid.apple.com';
export interface VerifyAppleIdTokenParams {
idToken: string;
clientId: string;
nonce?: string;
}
export default async (params: VerifyAppleIdTokenParams) => {
const decoded = jwt.decode(params.idToken, { complete: true });
const { kid, alg } = decoded.header;
const applePublicKey = await getApplePublicKey(kid);
const jwtClaims = jwt.verify(params.idToken, applePublicKey, {
algorithms: [alg],
nonce: params.nonce,
});
if (!jwtClaims.iss ||
jwtClaims.iss !== APPLE_BASE_URL) {
throw new Error(`The iss does not match the Apple URL - iss: ${jwtClaims.iss} |
expected: ${APPLE_BASE_URL}`);
}
if (jwtClaims.aud !== params.clientId) {
throw new Error(`The aud parameter does not include this client - is: ${jwtClaims.aud} |
expected: ${params.clientId}`);
}
return jwtClaims;
};
It's not surprising at all to consider the user field as our "username" and our identity token as our "password" in our Swift code (ASAuthorizationAppleIDCredential).
In the identity token the expiry field (exp) is 10 minutes after its issue date (iat).
Not so fast.
Sorry
That means Apple is telling us not to consider the identity token valid for more than 10 minutes.
Apple returns just enough information to verify users at the time of login and registration.
How we continue to trust them after that, without Apple's credentials is completely up to us.
A successful verification is trustworthy enough, for us to log our users in, or create them.
In my case it was JSON web tokens.
const { siwa_token, email, apple_id, first_name, last_name } = req.body;
const user = await User.findOne({ apple_id: apple_id });
if (user) {
if (email && email !== user.email) {
try {
await User.update({apple_id: apple_id}, { email: email, apple_id: apple_id })
} catch(error) {
res.status(400).send({ error: `${error}`});
}
user.email = req.body.email;
}
tokenService.verify(req.body, (response) => {
if (response && response.message) {
res.status(401).send(response.message);
} else {
let webToken = genToken(user)
res.status(200).send({ id: user._id, token: webToken });
}
})
}
else { //!user
tokenService.verify(req.body, (error) => {
if (error) {
res.status(401).send(error);
} else {
let newUser = User({
email: email,
apple_id: apple_id
})
let webToken = genToken(newUser)
newUser.save()
.then(() => {
res.status(200).json({
id: newUser._id,
token: webToken
});
})
.catch( error => {
res.status(400).json({ error: `${error}`});
})
}
})
}
Swift Client
Users are directed to providers, e.g., Apple, Google, Facebook, Twitter, Microsoft, Amazon (sigh). Users grant/deny permissions that the application requested. Providers direct users back to the application along with a token. The application then uses this token to sign in (or create an account).
NodeJS Backend
Use the token from the client to retrieve information from the provider, e.g., id, email. Use that information to sign in, or create an account if this is the first time.
Personal note: Implement JWT's on your backend so you can safely add new platforms in the future (web, android for instance)
🎈