Hi everyone,
I've been working on integrating Apple Sign-In with my web app and have hit a roadblock that I can't seem to resolve.
I've successfully set up an Nginx reverse-proxy for development purposes enabling SSL/TLS to provide HTTPS.
I have configured everything using the values from the Apple Developer Console, including identifiers, keys, and IDs.
The sign-in flow works perfectly when I use my Apple ID (which is linked to my developer account). The Apple Sign-In REST API returns a JWT with my email, as expected.
However, when other users sign in with their Apple IDs, the returned JWT doesn't include their email addresses. I am aware that Apple only provides the email on the first sign-in, but this doesn't seem to be the issue here.
Below is the relevant code I'm using (Bun.js, Elysia, Arctic):
import Bun from 'bun';
import { Apple, type AppleCredentials, type AppleTokens } from 'arctic';
import type { BaseAuthAccountInfo } from './type';
import { createPrivateKey } from 'crypto';
import { sign, decode } from 'jsonwebtoken';
const {
APPLE_CLIENT_ID,
APPLE_TEAM_ID,
APPLE_KEY_ID,
APPLE_CLIENT_SECRET,
APPLE_CLIENT_SECRET_JWT,
} = Bun.env;
type AppleReponseJWTPayload = {
iss: string;
aud: string;
exp: number;
iat: number;
sub: string;
at_hash: string;
email: string;
email_verified: boolean;
auth_time: number;
nonce_supported: boolean;
};
const credentials: AppleCredentials = {
clientId: APPLE_CLIENT_ID!,
teamId: APPLE_TEAM_ID!,
keyId: APPLE_KEY_ID!,
certificate: `-----BEGIN PRIVATE KEY-----\n${APPLE_CLIENT_SECRET}\n-----END PRIVATE KEY-----`,
};
const apple = new Apple(credentials, 'https://intellioptima.com/api/v1/aus/auth/apple/callback');
const appleAuthUrl = async (state: string) => {
const appleUrl = await apple.createAuthorizationURL(state);
appleUrl.searchParams.set('response_mode', 'form_post');
appleUrl.searchParams.set('scope', 'email');
return appleUrl;
};
const getAppleTokens = async (code: string) => {
console.log('Authorization code:', code);
const appleResponse = await apple.validateAuthorizationCode(code);
console.log('Apple Response:', appleResponse);
return appleResponse;
};
const getAppleAccount = async (tokens: AppleTokens): Promise<BaseAuthAccountInfo> => {
const token = generateJWTApple();
const response = await fetch('https://appleid.apple.com/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: credentials.clientId,
client_secret: token,
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken!,
}).toString(),
});
if (!response.ok) {
throw new Error('Failed to fetch user info');
}
const appleResponse = await response.json();
console.log('APPLE_RESPONSE', appleResponse);
const decodedUser = decode(appleResponse.id_token) as AppleReponseJWTPayload;
if (!decodedUser || !decodedUser.email) {
throw new Error('The user does not have an email address.');
}
return {
id: decodedUser.sub as string,
username: decodedUser.email.split('@')[0],
email: decodedUser.email!,
name: decodedUser.email.split('@')[0],
emailVerified: decodedUser.email_verified ?? false,
iconUrl: `https://robohash.org/${decodedUser.email.split('@')[0]}.png`,
};
};
function generateJWTApple() {
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const tokenKey = `-----BEGIN PRIVATE KEY-----\n${APPLE_CLIENT_SECRET_JWT!.replace(/\\n/g, '\n')}\n-----END PRIVATE KEY-----`;
const privateKey = createPrivateKey(tokenKey);
const now = Math.ceil(Date.now() / 1000);
const expires = now + MONTH * 3;
const claims = {
iss: APPLE_TEAM_ID,
iat: now,
exp: expires,
aud: 'https://appleid.apple.com',
sub: 'com.intellioptima.aichat',
};
return sign(claims, privateKey, {
header: {
kid: APPLE_KEY_ID,
alg: 'ES256',
},
});
}
export { apple, appleAuthUrl, getAppleAccount, getAppleTokens };
I would greatly appreciate any insights or suggestions on what might be going wrong. I'm at a loss, and any help would be invaluable!
Thanks in advance! <3333