Skip to content

Instantly share code, notes, and snippets.

@rxb
Last active April 24, 2024 16:26
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rxb/e596c66b03e3262f26d9ede5d7dbab81 to your computer and use it in GitHub Desktop.
Save rxb/e596c66b03e3262f26d9ede5d7dbab81 to your computer and use it in GitHub Desktop.
Sign in with Apple + FeathersJS: how to implement Apple's OAuth flow with FeathersJS

Sign in with Apple + FeathersJS

Upgrade to FeathersJS 5

FeathersJS uses a package called Grant for most of the OAuth functionality. Grant now supports the Apple OAuth flow, but only in recent versions. FeathersJS v5 (currently in prerelease) uses this new Apple-supporting version of Grant. For this reason, I would suggest first upgrading to FeathersJS v5 (Dove) https://dove.docs.feathersjs.com/guides/migrating.html

Pick a domain for localhost

Sign in with Apple does NOT work with servers set up as localhost. If you haven't already, you'll need to pick a domain for your FeatherJS server. If your main domain is example.com, you might want to pick a subdomain like devapi.example.com. You won't need to change the domain's real dns for this, you can add the alias to your /etc/hosts file:

127.0.0.1	      localhost
255.255.255.255	broadcasthost
::1               localhost
127.0.0.1	      devapi.example.com

Set up https

Sign in with Apple requires the OAuth client to be running https. If your dev environment isn't already https, you'll need to set this up.

This tutorial covers setting up your local key and certificate: https://gist.github.com/cecilemuller/9492b848eb8fe46d462abeb26656c4f8

Modify src/index.js to run https: (You'll need to update the paths of the cert and key)

const https = require('https');
const fs = require("fs");
const logger = require('./logger');
const app = require('./app');
const port = app.get('port');

const server = https.createServer({
  key: fs.readFileSync('/Users/Richard/localhost.key'),
  cert: fs.readFileSync('/Users/Richard/localhost.crt')
}, app).listen(443);

app.setup(server).then(server => {
  process.on('unhandledRejection', (reason, p) =>
    logger.error('Unhandled Rejection at: Promise ', p, reason)
  );
  logger.info('Feathers application started on http://%s:%d', app.get('host'), port)
});

Change these values on config/default.json

  "host": "devapi.example.com",
  "port": 443,
  "protocol": "https"

Get your credentials

The process of setting up your credentials for Sign in with Apple are not intuitive at all. This guide does a good job of stepping you through: https://medium.com/identity-beyond-borders/how-to-configure-sign-in-with-apple-77c61e336003

Important note: when setting up the Service ID, you will be asked for a Return URL. The return URL will be https://devapi.example.com/oauth/apple/callback (replacing devapi.example.com with your feathersjs server domain).

You should come away from this process with 4 credentials:

  • Private Key: this is the file that you downloaded in the keys step.
  • APPLE_CLIENT_ID: this is the string you entered in the Service ID identifier. It probably is the domain in reverse. (eg: com.example.app)
  • APPLE_KEY_ID: this is the string in the middle file name of the private key file between "AuthKey_" and ".p8"
  • APPLE_TEAM_ID: this is not something you set up as part of this process. It's a value in your developer account profile: https://developer.apple.com/account/#/membership

Put your credentials into environment variables

In MacOS, putting them in ~/.zshrc will do the trick. Here is what mine looks like (redacted): (Note that the first line reads-in your private key from the text file)

export APPLE_PRIVATE_KEY=`cat /USERS/RICHARD/AuthKey_XXXXXXXXXX.p8`
export APPLE_CLIENT_ID="com.example.devapi"
export APPLE_KEY_ID="XXXXXXXXXX"
export APPLE_TEAM_ID="XXXXXXXXXX"

Modify feathers oauth config

Static config values go in config/default.json This is just the oauth portion of the config object.

    "oauth": {
      "defaults": {
        "transport": "session",
        "origin": "https://devapi.example.com",
      },
      "redirect": "https://dev.example.com/",
      "apple": {
      	"scope": ["openid", "name", "email"],
        "response": ["raw", "jwt"],
        "nonce": true,
        "custom_params": {
          "response_type": "code id_token",
          "response_mode": "form_post"
        }
      }
    }

Dynamic values go in a file called config/custom-environment-variables.js. Aside from just providing some of these credentials back to Apple, we'll need to generate a token signed using the private key.

var jwt = require('jsonwebtoken');

const getAppleClientSecret = () => {
	const privateKey = process.env.APPLE_PRIVATE_KEY;
	const keyId = process.env.APPLE_KEY_ID;
	const teamId = process.env.APPLE_TEAM_ID;
	const clientId = process.env.APPLE_CLIENT_ID;

	const headers = {
		kid: keyId,
		typ: undefined
	}
	const claims = {
		'iss': teamId,
		'aud': 'https://appleid.apple.com',
		'sub': clientId,
	}
	token = jwt.sign(claims, privateKey, {
		algorithm: 'ES256',
		header: headers,
		expiresIn: '180d'
	});
	return token
}
process.env.APPLE_SECRET = getAppleClientSecret();

const config = {
  "authentication": {
    "oauth": {
      "apple": {
        "key": "APPLE_CLIENT_ID",
        "secret": "APPLE_SECRET"
      }
    }
  }
}
module.exports = config;

Note that this token will only be valid for 180 days, the max allowed by Apple. If you don't restart your Feathers server at least once every 180 days, this token will stop working.

Customize Apple OAuth strategy

We're almost there, but there is one final step. The user informaton comes encoded in the id_token JWT, which you'll need to help FeathersJS find Here's what my src/authentication.js looks like:

const { AuthenticationService, AuthenticationBaseStrategy, JWTStrategy } = require('@feathersjs/authentication');
const { LocalStrategy } = require('@feathersjs/authentication-local');
const { expressOauth, OAuthStrategy } = require('@feathersjs/authentication-oauth');

class AppleStrategy extends OAuthStrategy {
  async getProfile (data, _params) {
    return data.jwt.id_token.payload;
  }

  async getEntityData(profile) {  
    const baseData = await super.getEntityData(profile);
    const newData = {
      ...baseData,
      email: profile.email
    };
    return newData;
  }
}

module.exports = app => {
  const authentication = new AuthenticationService(app);
  authentication.register('jwt', new JWTStrategy());
  authentication.register('local', new LocalStrategy());
  authentication.register('apple', new AppleStrategy());
  app.use('/authentication', authentication);
  app.configure(expressOauth());
};

The End

Did you actually try this? Did it work? Let me know if you have suggestions for improving this guide. Good luck!

@oakgary
Copy link

oakgary commented Aug 24, 2022

This guide helped a lot, thank you for putting it up.

Since I got a little confused at this step: To check that your implementation works, you want to use https://<YOUR_LOCAL_HTTPS_DOMAIN>/oauth/apple/ to initialise the flow.

I initially skipped this step, directly accessing the URL https://appleid.apple.com/..., which would lead to the error error=Grant: missing session or misconfigured provider, which took me quite some time to figure out.

Besides, the framework will want to persist the returned appleId, so you will need to add something like appleId: { type: String, unique: true, sparse: true } to your user model.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment