Making your whole authentication process as seamless a possible is vital to ensuring that any potential users don't get put off by filling out a long-winded create account form.
Back in WWDC 2019 Apple introduced Sign in with Apple, this allows users to authenticate with their Apple Id with forced 2FA. A seamless solution to getting users into your app.
Today I will be running through the steps to integrate this into your managed Expo project using a Nest.js backend to authenticate that user and handle the user accounts.
Before we begin I will make the assumption the reader is familiar with:
- Expo
- Nest.js
- JavaScript
- TypeScript
Although the backend code will be using Nest, a lot of the code will be transferrable to any other Node backend framework you may be using.
What do we need?
Expo
For our managed Expo project, we will need to add:
-
"expo-apple-authentication"
by running:expo install expo-apple-authentication
which will install the version compatible with your SDK version. This tutorial is written for SDK41.
There are extra configuration steps you need to follow that can be found here: https://docs.expo.io/versions/latest/sdk/apple-authentication/#configuration
Backend
For our Nest.js backend we need to add:
-
https://github.com/auth0/node-jsonwebtoken (
npm install jsonwebtoken
)- We use this to verify the JWT token was signed by Apple's public key
-
https://github.com/auth0/node-jwks-rsa (
npm install jwks-rsa
)- We use this to retrieve the signing keys from Apple's keys API endpoint and to retrieve Apple's public key
-
https://github.com/auth0/jwt-decode (
npm install jwt-decode
)- We use this to decode the JWT generated by the Sign in with Apple button
A brief overview of the auth flow
- A user presses the Sign in with Apple button and logins in through the native Apple modal
- Once authorised through 2FA on the device, will return to us a JWT token with a nullable name value. (plus other values, we don't need to worry about these)
- Determine if the user is attempting to log in or create an account
- We send the JWT token off to our backend with the user's name (if they are creating an account)
- We then decode the JWT token, get Apple's public key and verify the JWT token was signed by Apple
- Validate the token against our bundle id
- At this point, we can safely assume they are authenticated with Apple and we can either login them in through our backend auth or create an account on our database using the values we have from step 2.
An overview of the Expo package
Expo's expo-apple-authenticator
is in effect a one-stop-shop with everything you need for your app.
The Sign-in with Apple button will only appear on iOS devices, so make sure to consider this when building the UI.
The package contains the functions for displaying the modal and even comes bundled with the button itself, although this isn't required if you want to use a custom button.
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={BUTTON_BORDER_RADIUS}
style={{
width: BUTTON_WIDTH,
height: BUTTON_HEIGHT,
}}
onPress={async () => {
//
}}
/>
There's also plenty of style options so the button fits in line with other buttons on the app. BUTTON_BORDER_RADIUS
, BUTTON_WIDTH
and BUTTON_HEIGHT
are custom values I declared for all buttons on the app.
When a user presses the Sign in with Apple button they will be presented with a native modal that sits on top of your app, with module fields dependent on the scope you requested.
You have access to two scopes:
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
If the user doesn't cancel the modal, the response will look something like:
export type AppleAuthenticationCredential = {
user: string;
state: string | null;
fullName: AppleAuthenticationFullName | null;
email: string | null;
realUserStatus: AppleAuthenticationUserDetectionStatus;
identityToken: string | null;
authorizationCode: string | null;
};
We only really care about 4 fields that get returned:
-
user
- this is a stable unique identifier that will persist between app versions/device changes for the app. -
fullName
- The user's name object, includes family name, given name, nickname etc. -
email
- The user's email, used for the backend to create a user account -
identityToken
- A JWT token that stores information about the audience, issuer and user.
Email and name access
Be warned!
The email
and fullName
will only be populated ONCE. The first time they press the button, this applies even if they change their device or update the app. A user's email is still available in the JWT token, but once fullName
gets pulled down the first time, you can never request it again and subsequent requests will return null.
Is the user creating an account or logging in?
As this button will act as both a create account button and a login button, how do we determine which the user is trying to do?
As mentioned above we know the first time they press the button, email and name values will be populated. If the name and email values are undefined, it is a fair assumption this is the users first time on the app and they are trying to create an account.
What is the problem with this? If the user signs in through the button, pulls down the name and email values but, say, the device crashes/the subsequent API call fails or they kill the app; they can't create an account through the Sign up with Apple button.
One mitigation of this is to cache the name value to the phone's storage if it is populated and so if any edge-case happens that causes the device to lose the name then we can fall back to the cache.
Save the name value to the cache with the key value of the credential.user
as we know this is a stable unique identifier.
// Expo Apple button
onPress={async () => {
try {
const credential: AppleAuthenticationCredential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const cachedName: string = await Cache.getAppleLoginName(credential.user);
const detailsArePopulated: boolean = (!!credential.fullName.givenName && !!credential.email);
if (!detailsArePopulated && !cachedName) {
await login(credential.identityToken);
} else if (!detailsArePopulated && cachedName) {
await createAccount(cachedName, credential.user, credential.identityToken);
} else {
await createAccount(
credential.fullName.givenName, credential.user, credential.identityToken,
);
}
} catch (error) {
if (error.code === 'ERR_CANCELED') {
onError('Continue was cancelled.');
} else {
onError(error.message);
}
}
}}
You can see in the above code snippet we are:
- Requesting the user's email and name
- Retrieving the name value stored in the cache (if any)
- If both the cache and credential values are undefined, we are assuming they have already used the button successfully to create an account, so start the login flow
- If the cached name is declared but the credential details are not, we are assuming something happened between requesting the values from Apple and the server creating them an account, so create an account using the cached name
- Otherwise, create an account with the requested values that are populated.
This is not bulletproof! If the user requests the details but the cache gets cleared/they change devices before creating an account, they will not be able to create an account using this flow. We hedged a bet that this was edge case enough to warrant the risk.
Decoding and validating the JWT token
If the user is creating an account or logging in, we need to handle decoding and validating the JWT token sent in the payload to our Nest.js API.
Using Nest.js we need to create two new endpoints to handle creating an account and logging in. Both these endpoints I will add a respective guard to. It's in this guard that we call the Apple auth strategy code.
From experience, I couldn't use the existing login and create account endpoints as these require the information we receive after decoding and validating the token, not once the request is made on the device.
Login guard:
public async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const token: string = <string>request.body.identityToken;
const jwt: JwtTokenSchema = await this.apple.ValidateTokenAndDecode(token);
try {
const [sessionId, user]: [string, IUser] = await this.login.AttemptLogin(jwt.email);
if (sessionId && user) {
request.user = user;
response.set('Auth-Token', sessionId);
response.set('Access-Control-Expose-Headers', 'Auth-Token');
return true;
}
} catch (error) {
throw new HttpException(`Validation failed for login. ${error.message ?? ''}`, HttpStatus.UNAUTHORIZED);
}
return false;
}
The most important line is:
const jwt: JwtTokenSchema = await this.apple.ValidateTokenAndDecode(token);
As we are injecting calling the Apple strategy to do most of the logic. The logic is as follows.
Getting Apple's public key
Before we use the JWT token, 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.
Firstly we need to decode the JWT token sent by the client and extract the kid
value found in the token's header.
import jwtDecode, { JwtHeader } from 'jwt-decode';
// rest of file
const tokenDecodedHeader: JwtHeader & { kid: string } = jwtDecode<JwtHeader & { kid: string }>(token, {
header: true,
});
The "kid" (key ID) Header Parameter is a hint indicating which key was used to secure the JWS. This parameter allows originators to explicitly signal a change of key to recipients. The structure of the "kid" value is unspecified. Its value MUST be a case-sensitive string. Use of this Header Parameter is OPTIONAL.
When used with a JWK, the "kid" value is used to match a JWK "kid" parameter value.
Then we need to perform an HTTP request to Apple's auth/keys
public endpoint to return an object which contains an array of keys. Read more about this here.
const applePublicKey: { keys: Array<{ [key: string]: string }> } = await this.api.Get(
'https://appleid.apple.com/auth/keys',
);
(this.api.Get
is a HTTP client we built for Nest, find out more here)
This endpoint will return a JSON Web Key Set which will look like:
{
"keys": [
{
"kty": "RSA",
"kid": "AIDOPK1",
"use": "sig",
"alg": "RS256",
"n": "lxrwmuYSAsTfn....",
"e": "AQAB"
},
{
//
}
]
}
As the keys array typically has more than one element, we need to filter the keys
array to the one that has a matching kid
value from the decoded header of the JWT token.
const kid: string = tokenDecodedHeader.kid;
const sharedKid: string = applePublicKey.keys.filter(x => x['kid'] === kid)[0]?.['kid'];
Now we need to get Apple's public key using the kid
value. To do this we use the package mentioned above: jwks-rsa
.
const client: jwksClient.JwksClient = jwksClient({
jwksUri: 'https://appleid.apple.com/auth/keys',
});
const key: jwksClient.CertSigningKey | jwksClient.RsaSigningKey = await client.getSigningKey(sharedKid);
const signingKey: string = key.getPublicKey();
If all is dandy, we should now have Apple's public key that we can use to verify our JWT token was signed with the same key. Thanks to jsonwebtoken
:
try {
const res: JwtTokenSchema = <JwtTokenSchema>jwt.verify(token, signingKey);
} catch (error) {
// token is invalid
}
I wrote my own JwtTokenSchema
for type safety:
export type JwtTokenSchema = {
iss: string;
aud: string;
exp: number;
iat: number;
sub: string;
nonce: string;
c_hash: string;
email: string;
email_verified: string;
is_private_email: string;
auth_time: number;
};
Now we have the decoded JWT token we just need to perform some basic validation before we proceed with the rest of the auth process.
private ValidateToken(token: JwtTokenSchema): void {
if (token.iss !== 'https://appleid.apple.com') {
throw { message: 'Issuers do not match!' };
}
if (token.aud !== this.audience) {
throw { message: 'Audiences do not match!' };
}
}
-
this.audience
is your app's bundle ID. This will change depending on if you are on Expo or in production. I do a ternary in the class' constructor to set this:
this.audience = this.isInProd ? 'co.repetitio.repetitio' : 'host.exp.Exponent' // this will be your bundle id, found in app.json
Now we should have the decoded token that we have validated against Apple's public key and can safely assume that the user is whom they say they are and are trying to login into the correct app. The full class is linked at the bottom.
From here you are free to proceed to the log in process or create them an account.
I have uploaded the code to a public repo so you can take a deeper look here.
Thanks for reading!
Top comments (2)
Hi, I'm trying to test that in a simulator with iOS 13.6 and I can see the native modal, but when I complete the password field properly it seems that success, but it doesn't give me a response. Did you have a similar problem?
I solved the same problem by enabling Sign In with Apple
in the Certificates, Identifiers & Profiles section in my apple developer account