DEV Community

Harry Adel
Harry Adel

Posted on

Apple Sign-In using Hapijs

Recently, I've been tasked with enabling Apple Sign-In using Hapijs but the content online that relates to this part is very scarce. So, I've decided to come up with my own little tutorial that connects everything together from the start to the very end.

This tutorial is split up into two parts, the first is about configuring your Apple developer account and getting the required credentials while the second part is how to use these credentials to set up the authentication flow.

Part 1

We need to attain the following credentials from Apple:

Team/App ID

Apple lets your create an application that encompasses all of your services. It might seem like an over kill but it makes sense when you've an application that has multiple fronts like web, IOS or Vision OS, you name it. It's one huge umbrella for all of your "team" needs. The biggest takeaway here is to write down the Team ID you get when you create an application and also be sure to configure it to allow Apple sign-in.

Register an App

Check sign in with Apple

Service/Client ID

Now, we're going to create a services or a client that initiates such login attempts. As an example, I'm going to name our application "devtools" and the domain for it is "devtools.io". Apple is going to require you to provide an identifier for your service and it's customary to enter in your domain reversed, in our case "io.devtools". This is going to be our Client ID, so don't forget to write it down. Keep in mind, Apple doesn't allow entering localhost so put down any name and later in this tutorial, I'll show you to test things locally easily.

Image description

Image description

Image description

Image description

Key ID

Finally, Apple requires you to use a private key to sign your communications with it for an extra layer of protection. You're going to download a p8 file that we're going to use later on. Don't forget to write down the Key ID.

Image description

Image description

Image description

When you're done with part you should end up with the following env variables:

  • APPLE_CLIENTID
  • APPLE_TEAMID
  • APPLE_KEYID
  • APPLE_CALLBACK

And the .p8 key file.

Part 2

Before diving into the code, there're few adjustments we need to make in order to be able to run our application locally on HTTPs and to also route to the proper domain name.

Generating locally signed SSL certificates

We're going to create self signed certificates in order to be able to use them in our application and place them in a directory called certs.

openssl req -x509 -out localhost.crt -keyout localhost.key \
  -newkey rsa:2048 -nodes -sha256 \
  -subj "/CN=localhost" -extensions EXT -config <( \
   printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
Enter fullscreen mode Exit fullscreen mode

Host configuration

Go ahead and edit /etc/hosts so that devtools.io routes to our application.

127.0.0.1  devtools.io
Enter fullscreen mode Exit fullscreen mode

In our index.js, use our locally signed certificates:

const server = Hapi.server({
  host: process.env.HOST,
  port: 443, // Default HTTPS port
  tls: {
    key: fs.readFileSync(
      path.resolve(__dirname, "certs/localhost.key"),
      "utf8"
    ),
    cert: fs.readFileSync(
      path.resolve(__dirname, "certs/localhost.crt"),
      "utf8"
    ),
  },
});
Enter fullscreen mode Exit fullscreen mode

You might have to use sudo to run your application. Now fire up your browser and visit devtools.io, and you'd be met with something that looks like this:

Localhost

Type in thisisunsafe in your browser so that chrome'd allow to visit the website.

Configuring Apple Strategy

The meaty part which you've been looking for. Apologies if this part is messy but it was no easy task. But I'll try to explain things to the best of my ability.

profile function is triggered after a successful authorization/token request. Because Apple is the cool company we all know and love, to verify the id_token it sends back you have to fetch a list of public keys and match it with key ID (kid) in header of the received token.

We then use jwk-to-pem to convert the public key into something jsonwebtoken can use to verify the signature.

Also, you'd find that I parse a property called credentials since I asked for the user's email and name in scope but it's something I had to monkey patch into the library.

https://github.com/hapijs/bell/pull/494

const apple = {
    auth: "https://appleid.apple.com/auth/authorize",
    token: "https://appleid.apple.com/auth/token",
    name: "apple",
    protocol: "oauth2",
    useParamsAuth: true,
    profile: async (credentials, params) => {
      const decodedToken = jwt.decode(params.id_token, { complete: true });
      const applePublicKey = await axios.get(
        `https://appleid.apple.com/auth/keys`
      );
      const rsaKey = applePublicKey.data.keys.find(
        (key) => key.kid === decodedToken.header.kid
      );

      try {
        const resp = await jwt.verify(params.id_token, jwkToPem(rsaKey), {
          algorithms: [decodedToken.header.alg],
        });

        credentials.profile = {
          id: resp.sub,
          email: JSON.parse(credentials.user).email,
          displayName: `${JSON.parse(credentials.user).name.firstName} ${
            JSON.parse(credentials.user).name.lastName
          }`,
        };
        return credentials;
      } catch (e) {
        console.log("Token verification failed");
      }
    },
  };
Enter fullscreen mode Exit fullscreen mode

We finally get to use the .p8 key Apple gave us at the very beginning to sign our token.


const privateKey = fs.readFileSync(
  path.resolve(__dirname, "./secret_key/AuthKey_XSYG2M3R.p8"),
  "utf8"
);

function getSecretKey() {
  const claims = {
    iss: process.env.APPLE_TEAMID,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 86400 * 180,
    aud: "https://appleid.apple.com",
    sub: process.env.APPLE_CLIENTID,
  };

  let signOptions = {
    algorithm: "ES256", // you must use this algorithm, not jsonwebtoken's default
    keyid: process.env.APPLE_KEYID,
  };

  const token = jwt.sign(claims, privateKey, signOptions);

  return token;
}
Enter fullscreen mode Exit fullscreen mode
server.auth.strategy("apple", "bell", {
    clientId: process.env.APPLE_CLIENTID,
    clientSecret: getSecretKey,
    provider: apple,
    forceHttps: true,
    isSecure: true,
    scope: ["email", "name"],
    providerParams: { response_mode: "form_post" },
    password: "cookie_encryption_password_secure",
    location: () => process.env.APPLE_CALLBACK,
  });
Enter fullscreen mode Exit fullscreen mode

It's very crucial to add this piece of code as bell is unable to route you properly back to the application. Shout out to annahassel

const fixAppleCallbackForBell = (request, h) => {
  if (request.method === "post" && request.path === "/api/auth/apple") {
    const {
      raw: { req },
    } = request;
    let payload = "";

    return new Promise((resolve, reject) => {
      req.on("error", reject);
      req.on("data", (chunk) => {
        payload += chunk;
      });

      req.on("end", () => {
        request.setUrl(`/api/auth/apple?${payload}`);
        resolve(h.continue);
      });
    });
  }

  return h.continue;
};

server.ext("onRequest", fixAppleCallbackForBell);
Enter fullscreen mode Exit fullscreen mode

That's pretty much it. Let me know if you've any questions.


This article wouldn't have been possible without the knowledge shared in these articles:


Researching and sharing knowledge in this article took quite a considerable amount of time. I'd appreciate if you can sponsor me on Github.

Top comments (0)