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
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
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"
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
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"
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.
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());
};
Did you actually try this? Did it work? Let me know if you have suggestions for improving this guide. Good luck!
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 errorerror=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.