Skip to content

Commit

Permalink
🍎 feat: Apple auth (danny-avila#5473)
Browse files Browse the repository at this point in the history
* implemented Apple Auth login.

Closes: danny-avila#3438

TODO:
- write config Doc

* removed some comments

* removed comment

* Add unit tests for Apple login strategy

Introduce comprehensive tests for the Apple login strategy, covering new user creation, existing user updates, and error handling scenarios during the authentication flow. Mocks implemented for external dependencies to ensure isolated testing.

* Remove unnecessary blank line in socialLogins.js
  • Loading branch information
rubentalstra authored and justinmdickey committed Feb 7, 2025
1 parent e81fd75 commit 4cb69d1
Show file tree
Hide file tree
Showing 21 changed files with 545 additions and 13 deletions.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,13 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=/oauth/google/callback

# Apple
APPLE_CLIENT_ID=
APPLE_TEAM_ID=
APPLE_KEY_ID=
APPLE_PRIVATE_KEY_PATH=
APPLE_CALLBACK_URL=/oauth/apple/callback

# OpenID
OPENID_CLIENT_ID=
OPENID_CLIENT_SECRET=
Expand Down
6 changes: 6 additions & 0 deletions api/models/schema/userSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const { SystemRoles } = require('librechat-data-provider');
* @property {string} [ldapId] - Optional LDAP ID for the user
* @property {string} [githubId] - Optional GitHub ID for the user
* @property {string} [discordId] - Optional Discord ID for the user
* @property {string} [appleId] - Optional Apple ID for the user
* @property {Array} [plugins=[]] - List of plugins used by the user
* @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens
* @property {Date} [expiresAt] - Optional expiration date of the file
Expand Down Expand Up @@ -111,6 +112,11 @@ const userSchema = mongoose.Schema(
unique: true,
sparse: true,
},
appleId: {
type: String,
unique: true,
sparse: true,
},
plugins: {
type: Array,
default: [],
Expand Down
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2",
"passport": "^0.6.0",
"passport-apple": "^2.0.2",
"passport-custom": "^1.1.1",
"passport-discord": "^0.1.4",
"passport-facebook": "^3.0.0",
Expand Down
5 changes: 5 additions & 0 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ router.get('/', async function (req, res) {
!!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET,
googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
appleLoginEnabled:
!!process.env.APPLE_CLIENT_ID &&
!!process.env.APPLE_TEAM_ID &&
!!process.env.APPLE_KEY_ID &&
!!process.env.APPLE_PRIVATE_KEY_PATH,
openidLoginEnabled:
!!process.env.OPENID_CLIENT_ID &&
!!process.env.OPENID_CLIENT_SECRET &&
Expand Down
33 changes: 33 additions & 0 deletions api/server/routes/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ router.get(
oauthHandler,
);

/**
* Facebook Routes
*/
router.get(
'/facebook',
passport.authenticate('facebook', {
Expand All @@ -77,6 +80,9 @@ router.get(
oauthHandler,
);

/**
* OpenID Routes
*/
router.get(
'/openid',
passport.authenticate('openid', {
Expand All @@ -94,6 +100,9 @@ router.get(
oauthHandler,
);

/**
* GitHub Routes
*/
router.get(
'/github',
passport.authenticate('github', {
Expand All @@ -112,6 +121,10 @@ router.get(
}),
oauthHandler,
);

/**
* Discord Routes
*/
router.get(
'/discord',
passport.authenticate('discord', {
Expand All @@ -131,4 +144,24 @@ router.get(
oauthHandler,
);

/**
* Apple Routes
*/
router.get(
'/apple',
passport.authenticate('apple', {
session: false,
}),
);

router.post(
'/apple/callback',
passport.authenticate('apple', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
}),
oauthHandler,
);

module.exports = router;
4 changes: 4 additions & 0 deletions api/server/socialLogins.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
githubLogin,
discordLogin,
facebookLogin,
appleLogin,
} = require('~/strategies');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
Expand All @@ -30,6 +31,9 @@ const configureSocialLogins = (app) => {
if (process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET) {
passport.use(discordLogin());
}
if (process.env.APPLE_CLIENT_ID && process.env.APPLE_PRIVATE_KEY_PATH) {
passport.use(appleLogin());
}
if (
process.env.OPENID_CLIENT_ID &&
process.env.OPENID_CLIENT_SECRET &&
Expand Down
53 changes: 53 additions & 0 deletions api/strategies/appleStrategy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const socialLogin = require('./socialLogin');
const { Strategy: AppleStrategy } = require('passport-apple');
const { logger } = require('~/config');
const jwt = require('jsonwebtoken');

/**
* Extract profile details from the decoded idToken
* @param {Object} params - Parameters from the verify callback
* @param {string} params.idToken - The ID token received from Apple
* @param {Object} params.profile - The profile object (may contain partial info)
* @returns {Object} - The extracted user profile details
*/
const getProfileDetails = ({ idToken, profile }) => {
if (!idToken) {
logger.error('idToken is missing');
throw new Error('idToken is missing');
}

const decoded = jwt.decode(idToken);

logger.debug(
`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`,
);

return {
email: decoded.email,
id: decoded.sub,
avatarUrl: null, // Apple does not provide an avatar URL
username: decoded.email
? decoded.email.split('@')[0].toLowerCase()
: `user_${decoded.sub}`,
name: decoded.name
? `${decoded.name.firstName} ${decoded.name.lastName}`
: profile.displayName || null,
emailVerified: true, // Apple verifies the email
};
};

// Initialize the social login handler for Apple
const appleLogin = socialLogin('apple', getProfileDetails);

module.exports = () =>
new AppleStrategy(
{
clientID: process.env.APPLE_CLIENT_ID,
teamID: process.env.APPLE_TEAM_ID,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`,
keyID: process.env.APPLE_KEY_ID,
privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH,
passReqToCallback: false, // Set to true if you need to access the request in the callback
},
appleLogin,
);
Loading

0 comments on commit 4cb69d1

Please sign in to comment.