A frictionless sign-up and sign-in experience has many benefits.
Once a user is ready to try our app, don't lose them in the sign up procedure.
Once a user starts using our app, don't lose them becuase they forgot their login.
Multifactor and not reusing passwords keeps user's data safe.
We already wrote about sign-in & sign-up with Google and Microsoft here.
In this blog we want to look at another provider of authentication services - Apple.
There are 1.5 billion active iPhone users.
All of them will be able to sign-in and sign-up with TouchID or FaceID, never-ever needing a password.
We believe this is a massive benefit for the users. It will also make your app stand out.
The technology is called Sign in with Apple and by now you have surely seen it on the web.
While researching this topic, we haven't found a good blog or npm package. There is some info scattered around the web.
And there is even a PassportJS package that is close, but is missing critical features (like getting the user's email).
Then, there is the absence of any testing environment by Apple, which makes it really hard to develop.
Long story short, it took us hours to piece the whole picture together. And several more hours of deploying test versions until it all worked.
Before we delve into the code, we have to provide information to Apple.
You will also have to sign-up for a developer account with Apple.
Keep in mind that this is a paid service.
First we will create an new App ID (or use an existing app if you have one).
Now that we have the App ID, we can create the Services ID.
To recap, by now you should have an App ID and a Services ID ready and configured.
Now we have to make a key to secure the communication between our app and Apple.
You should now have an App ID, Services ID and a Key.
Check if you have written down the required ids (team_id in the app, client_id, and key_id).
Make sure you have the key downloaded and safe.
Sign in with Apple follows the OpenID or OAuth flows. If you've built or seen an implementation, it will look familiar.
However Apple decided to significantly up the game, so there is also a lot of new and unique things to implement.
The implementation below is for NodeJS backend service.
The protocol is conceptually straightforward. In case you need a refresher, here are all the steps in detail:
Let's see how this work for Apple:
Please follow the guidelines for the Sign in with Apple button design.
It is always a good idea to provide familiar UI, even more when dealing with security.
For our implementation the button is a link to /auth/apple
The NodeJS handler will prepare the authentication URL and redirect to apple.
Please note:
app.get("/auth/apple", (req, res) => {
const scope = 'email'; // we want to know the user's email
const state = crypto.randomBytes(19).toString("hex");;
const redirectUri = "https://inuko.net/auth/apple/callback";
const client_id = APPLE_CLIENT_ID; // something like com.ourdomain.app
const authorizationUri = `https://appleid.apple.com/auth/authorize?response_type=code id_token&client_id=${client_id}&redirect_uri=${redirectUri}&state=${state}&scope=${scope}&response_mode=form_post`;
res.redirect(authorizationUri);
});
For the following code to work we need to add 2 npm packages.
Install both with npm install node-rsa jsonwebtoken
.
Once Apple redirects back to our backend service we get 3 pieces of info.
The CODE, the id_token (identifying who is calling our web api) and state, which is just echoed back.
app.post('/auth/apple/callback', async (req, res, next) => {
const { code, id_token, state } = req.body;
//...
}
The first step is verifing that we are actually called by Apple.
Now this is not stricly required and the rest of the code will work without this step.
But, it is good practice. Please check the comments in the code for what's going on.
const verify_apple_id_token = async (id_token: string) => {
// 1. Decode the id_token into parts. We need the header, so we pass the options complete: true.
const parts = jwt.decode(id_token, { complete: true });
// 2. Fetch apple public keys. There will be several. Cache if needed.
const keyResp = await fetch(`https://appleid.apple.com/auth/keys`);
const keyCollection = await keyResp.json();
// 3. Find the right key in the key collection, that matches the token's key id (kid) and the algorithm used (alg).
const publicKey64 = keyCollection.keys.find((x: any) => x.kid === parts.header.kid && x.alg === parts.header.alg);
// 4. Transform the key information into a usable format.
const NodeRSA = require("node-rsa")
const pubKey = new NodeRSA();
pubKey.importKey({ n: Buffer.from(publicKey64.n, 'base64'), e: Buffer.from(publicKey64.e, 'base64') }, 'components-public');
const pk = pubKey.exportKey(['public']);
// 5. Verify the token was made by Apple. This will THROW an exception, if validation fails!
jwt.verify(id_token, pk, { algorithms: [parts.header.alg] });
}
When we are sure, that it is trully Apple calling our backend service, we can fetch the user's details.
Hold your horses, not so fast, first we need the secret. The client_secret to be more precise.
Don't worry, we have all the ingredients, we just have to mix them in the right way.
Please check your notes to get them:
const make_appleid_client_secret = () => {
const payload = {
iss: APPLE_TEAM_ID,
iat: new Date().valueOf() / 1000,
exp: new Date(new Date().valueOf() + 86400 * 1000 * 100).valueOf() / 1000,
aud: "https://appleid.apple.com",
sub: APPLE_CLIENT_ID
};
const private_key = APPLE_PRIVATE_KEY;
const clientSecret = jwt.sign(payload, key, { algorithm: 'ES256', header: { "kid": APPLE_KEY_ID } });
return clientSecret;
}
The secret we created above is valid for 100 days (for any user). The maximum allowed by Apple is 180 days.
If you decide you want to cache it, make sure you keep it safe!
Finally we can call Apple and get the user's email, so that we can check they are indeed our user and log them in.
const fetch_appleid_user_info = async (code, clientSecret, state) =>{
const d = new URLSearchParams();
d.append('grant_type', 'authorization_code');
d.append('code', code);
d.append('client_id', APPLE_CLIENT_ID);
d.append('client_secret', clientSecret);
d.append('redirect_uri', "https://inuko.net/auth/apple/callback");
d.append('scope', "email");
d.append('state', state);
const resp = await fetch("https://appleid.apple.com/auth/token", { method: "POST", body: d });
const jresp = await resp.json();
if (!jresp.id_token) {
throw Error("Apple Login Error - no token - 0x2");
}
const token = jwt.decode(jresp.id_token);
//const appleUserId = token.sub; // UNIQUE User identifier.
const appleUserEmail = token.email;
if (!appleUserEmail) {
throw Error("Apple Login Error - no email - 0x3");
}
return appleUserEmail;
}
Let's put all the pieces together now.
const jwt = require("jsonwebtoken");
app.post('/auth/apple/callback', async (req, res, next) => {
try {
const { code, id_token, state } = req.body;
await verify_apple_id_token(id_token);
const clientSecret = make_appleid_client_secret();
const userEmail = await fetch_appleid_user_info(code, clientSecret, state);
const user = await get_user_from_database_by_email(userEmail); // Replace with your actual code...
if (!user)
throw Error("User not found!");
req.login(user, err => {
if (err) {
next(err);
} else {
// SUCCESS - go to app
res.redirect("/");
}
})
}
catch (err) {
next(err);
}
});
Putting this together felt a lot like building a large Lego set without any instructions.
You know, like figuring our which parts have similar colors and fit together.
I hope you find the explanation and the code above helpful.
Whether you enjoy this kind of work or you just like to have this feature in your app:)
Happy hacking!