Apple Sign-In using Hapijs
Tags: node, webdev, tutorial, hapijs
Published: 2024-03-30 13:32:01

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lgszlpwaaiiyrng7ivtb.jpg)

![Check sign in with Apple](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ytj8xeidg1x9lpgsle2s.jpg)


### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9ergu797pqww4iarq2l7.jpg)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x98z8s7dcfhwk4yiye52.jpg)

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q5sodf27q6shovu6hyg1.jpg)

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x3od6reb81jmip2wlfda.jpg)


### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gqtw51fl8q4vjq0d6n46.jpg)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ph3zi866c3u0kkntmk80.jpg)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t1fkga9omniib8sqocyi.jpg)


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")
```

### Host configuration

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

```bash
127.0.0.1  devtools.io
```

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

```js
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"
    ),
  },
});
```
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/isyvrk2uwkh9twoywkle.jpg)

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](https://www.npmjs.com/package/jwk-to-pem) to convert the public key into something [jsonwebtoken](https://www.npmjs.com/package/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

```js
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");
      }
    },
  };
``` 
We finally get to use the `.p8` key Apple gave us at the very beginning to sign our token.  

```js

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;
}
```

```js
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,
  });
```
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](https://github.com/hapijs/bell/issues/462#issuecomment-812033842)

```js
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);
```

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:
- https://codeculturepro.medium.com/how-to-implement-login-with-apple-in-node-js-7a709969f89d
- https://medium.com/@artyomefremov/add-sign-in-with-apple-button-to-your-website-today-part-1-12ed1444623a
- https://sarunw.com/posts/sign-in-with-apple-1/
- https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
- https://codeculturepro.medium.com/how-to-implement-login-with-apple-in-node-js-7a709969f89d
- https://blog.devgenius.io/how-to-implement-apple-login-with-nestjs-in-seconds-b88f05abe847
- https://github.com/hapijs/bell/issues/462
- https://stackoverflow.com/questions/75479798/how-to-setup-apple-sign-in-using-hapis-bell

-------

Researching and sharing knowledge in this article took quite a considerable amount of time. I'd appreciate if you can sponsor me on [Github](https://github.com/sponsors/harryadel/).