Implementing Sign in with Apple on the Server Side with Node.js
Implementing Sign in with Apple on the backend can be challenging due to the lack of comprehensive, step-by-step guides. In this article, I’ll walk you through the process, making it easier for you to integrate Apple’s sign-in feature on your server side.
Before we dive in, set up your Apple Developer account.
Log into your Apple Developer account and navigate to “Certificates, Identifiers & Profiles”. Then create an App ID and Service ID
. The Service ID will be used as your clientId
.
Generate a new key in the “Keys” menu and enable “Sign in with Apple”.
Download the key file (AuthKey_KEYID.p8
) and save it securely.
Once you’re ready, return here to continue.
Overview of Sign in with Apple
When a user signs in with Apple, your client app will receive an authorizationCode and an idToken. Your job on the backend is to validate this authorizationCode and authenticate the user.
Related documentation: Request an Authorization to the Sign in with Apple Server.
URL for Authentication Testing
You can use the following URL to obtain your authCode and idToken for testing your future API:
appleid.apple.com/auth/authorize?response_type=code id_token&nonce=[A_HASH_FOR_YOUR_VALIDATION]&client_id=[YOUR_CLIENT_ID]&redirect_uri=[YOUR_CALLBACK_URI]&state=[SOMETHING_AS_STATE]&scope=email&response_mode=form_post
Backend Setup
To implement the sign-in feature in your backend, you’ll need the following credentials:
export type AppleSignInCredentials = {
privateKey: string;
teamId: string;
keyId: string;
clientId: string;
}
Required Libraries
Before implementing, install the necessary libraries: jose
for working with JWT and axios
for making HTTP calls:
npm install axios jose qs
Creating the AppleAuthHandler Class
We’ll implement a class named AppleAuthHandler as follows:
export class AppleAuthHandler {
private readonly _credentials: AppleSignInCredentials;
clientSecret?: string;
static publicKeys: JWK[] = [];
constructor(credentials: AppleSignInCredentials) {
this._credentials = credentials;
}
// ... (other methods)
}
Generating a Client Secret
To communicate with Apple’s server, you need to generate a clientSecret. Here’s how you can do it:
async updateClientSecret(): Promise<string> {
try {
const currentTime = Math.floor(Date.now() / 1000);
const privateKey = await importPKCS8(this._credentials.privateKey, 'ES256');
// Create and sign the JWT
const jwt = await new SignJWT({
iss: this._credentials.teamId,
iat: currentTime,
exp: currentTime + 60 * 60 * 24, // 1 day expiration
aud: "https://appleid.apple.com",
sub: this._credentials.clientId
})
.setProtectedHeader({ alg: 'ES256', kid: this._credentials.keyId })
.sign(privateKey);
this.clientSecret = jwt;
return jwt;
} catch (error) {
console.log('Error creating client secret:', error);
throw error;
}
}
Fetching Apple’s Public Keys
To perform further validations, you need to fetch Apple’s public keys:
private static async updatePublicKey(): Promise<JWK[]> {
try {
const url = 'https://appleid.apple.com/auth/oauth2/v2/keys';
const { status, data } = await axios.get(url);
if ([200, 201, 204].includes(status)) {
const keys = data.keys;
if (keys && keys.length > 0) {
AppleAuthHandler.publicKeys = keys;
return keys;
} else {
console.log('No keys found in the response.');
throw new Error('No keys found in the response');
}
}
throw {
message: 'Failed to execute HTTP request',
status,
data,
};
} catch (error) {
console.error('Error fetching keys:', error);
throw error;
}
}
Validating User Credentials
To validate the incoming authorizationCode
, we need to exchange it for an idToken
and get user information from Apple’s server. Implement the retrieveToken
function:
private async retrieveToken(authorizationCode: string): Promise<AppleSignInTokenResponse> {
if (!this.clientSecret) {
throw new Error('Client secret not set. Please call initialize() first.');
}
try {
const requestData = qs.stringify({
'client_id': this._credentials.clientId,
'client_secret': this.clientSecret,
'code': authorizationCode,
'grant_type': 'authorization_code'
});
const { data, status } = await axios({
method: 'post',
url: 'https://appleid.apple.com/auth/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Axios/1.2.0',
'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate, br'
},
data: requestData
});
if ([200, 201, 204].includes(status)) {
return {
idToken: data.id_token,
refreshToken: data.refresh_token,
accessToken: data.access_token,
tokenType: data.token_type,
expiresIn: data.expires_in
};
}
throw {
message: 'Failed to execute HTTP request',
status,
data,
};
} catch (error) {
console.error('Error retrieving token:', error.response.data);
throw error.response.data;
}
}
Note: Ensure the request content type is application/x-www-form-urlencoded
.
Decoding and Validating the idToken
When you get Apple’s public keys, you’ll receive a couple of keys. Select the proper public key based on the kid
of your idToken
. Here’s how you can do it:
Related documentation: Fetch Apple’s public key for verifying token signature.
static async getPublicKey(kid: string): Promise<JWK> {
if (AppleAuthHandler.publicKeys.length === 0) {
await AppleAuthHandler.updatePublicKey();
}
const foundKey = AppleAuthHandler.publicKeys.find(key => key.kid === kid);
if (foundKey) {
return foundKey;
}
await AppleAuthHandler.updatePublicKey();
const foundKeySecondAttempt = AppleAuthHandler.publicKeys.find(key => key.kid === kid);
if (foundKeySecondAttempt) {
return foundKeySecondAttempt;
}
throw new Error('AppleAuthHandler: Public key not found');
}
Decoding the idToken
Now, let’s decode and validate the retrieved idToken
:
private static async decodeIdToken(idToken: string) {
if (AppleAuthHandler.publicKeys.length === 0) {
throw new Error('Apple PublicKey not set. Please call initialize() first.');
}
const idTokenHeader = await decodeProtectedHeader(idToken);
const relatedPublicKey = await AppleAuthHandler.getPublicKey(idTokenHeader.kid);
const publicKey = await importJWK(relatedPublicKey);
const { payload } = await jwtVerify(idToken, publicKey);
return payload;
}
Validate and Return User Information
Finally, validate the decoded token and return the user information:
public async validateUserCredentials({ authorizationCode, nonce }: { authorizationCode: string, nonce?: string }): Promise<AppleUserRetrievedData> {
try {
const tokenResponse = await this.retrieveToken(authorizationCode);
const decodedToken = await AppleAuthHandler.decodeIdToken(tokenResponse.idToken);
const { iss, sub, aud, email, email_verified, is_private_email, nonce } =
decodedToken;
// Validate token properties
this.validateTokenProperties(iss, aud, sub, email_verified, clientNonce, nonce);
return {
aud,
email,
emailVerified,
isPrivateEmail,
sub,
};
} catch (error) {
throw error;
}
}
private validateTokenProperties(
iss: string,
aud: string,
sub: string,
emailVerified: boolean,
nonce?: string,
clientNonce?: string,
): void {
if (!iss.includes('https://appleid.apple.com')) {
throw new AppleAuthError('Token issuer is invalid.');
}
if (aud !== this.credentials.clientId) {
throw new AppleAuthError('Token audience is invalid.');
}
if (!sub) {
throw new AppleAuthError('Token subject is invalid.');
}
if (!emailVerified) {
throw new AppleAuthError('Email not verified.');
}
if (nonce && nonce !== clientNonce) {
throw new AppleAuthError('Nonce is invalid.');
}
}
use the payload.sub
as unique id to save or retrieve user information.
Conclusion
Congratulations! You now have the user information from Apple’s sign-in process. Validate the necessary details and proceed with your application’s logic.
I published the refined library to NPM:
npm install third-auth
you can visit the repository for more information and the usage.
Don’t forget to like this article because I probably saved you a couple of days! 😀 Trust me.