From e1a6268904bc671eec3261217e4c5695a5eeb7f0 Mon Sep 17 00:00:00 2001 From: Ruben Talstra Date: Fri, 31 Jan 2025 15:49:09 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=8E=20feat:=20Apple=20auth=20(#5473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * implemented Apple Auth login. Closes: #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 --- .env.example | 7 + api/models/schema/userSchema.js | 6 + api/package.json | 1 + api/server/routes/config.js | 5 + api/server/routes/oauth.js | 33 ++ api/server/socialLogins.js | 4 + api/strategies/appleStrategy.js | 53 +++ api/strategies/appleStrategy.test.js | 376 ++++++++++++++++++ api/strategies/discordStrategy.js | 2 +- api/strategies/facebookStrategy.js | 2 +- api/strategies/githubStrategy.js | 2 +- api/strategies/googleStrategy.js | 2 +- api/strategies/index.js | 2 + api/strategies/socialLogin.js | 6 +- .../src/components/Auth/SocialLoginRender.tsx | 23 +- client/src/components/svg/AppleIcon.tsx | 18 + client/src/components/svg/index.ts | 1 + client/src/localization/languages/Eng.ts | 1 + librechat.example.yaml | 2 +- package-lock.json | 11 + packages/data-provider/src/config.ts | 1 + 21 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 api/strategies/appleStrategy.js create mode 100644 api/strategies/appleStrategy.test.js create mode 100644 client/src/components/svg/AppleIcon.tsx diff --git a/.env.example b/.env.example index 88c5e462a..4f6fbe091 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/api/models/schema/userSchema.js b/api/models/schema/userSchema.js index 57b96342d..f58655336 100644 --- a/api/models/schema/userSchema.js +++ b/api/models/schema/userSchema.js @@ -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.} [refreshToken] - List of sessions with refresh tokens * @property {Date} [expiresAt] - Optional expiration date of the file @@ -111,6 +112,11 @@ const userSchema = mongoose.Schema( unique: true, sparse: true, }, + appleId: { + type: String, + unique: true, + sparse: true, + }, plugins: { type: Array, default: [], diff --git a/api/package.json b/api/package.json index caeccc5bf..26fd625ea 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/routes/config.js b/api/server/routes/config.js index f6669169a..705a1d3cb 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -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 && diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index dd4370afe..046370798 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -56,6 +56,9 @@ router.get( oauthHandler, ); +/** + * Facebook Routes + */ router.get( '/facebook', passport.authenticate('facebook', { @@ -77,6 +80,9 @@ router.get( oauthHandler, ); +/** + * OpenID Routes + */ router.get( '/openid', passport.authenticate('openid', { @@ -94,6 +100,9 @@ router.get( oauthHandler, ); +/** + * GitHub Routes + */ router.get( '/github', passport.authenticate('github', { @@ -112,6 +121,10 @@ router.get( }), oauthHandler, ); + +/** + * Discord Routes + */ router.get( '/discord', passport.authenticate('discord', { @@ -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; diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 6da71f908..ec3a73e0a 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -9,6 +9,7 @@ const { githubLogin, discordLogin, facebookLogin, + appleLogin, } = require('~/strategies'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); @@ -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 && diff --git a/api/strategies/appleStrategy.js b/api/strategies/appleStrategy.js new file mode 100644 index 000000000..a45f10fc6 --- /dev/null +++ b/api/strategies/appleStrategy.js @@ -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, + ); diff --git a/api/strategies/appleStrategy.test.js b/api/strategies/appleStrategy.test.js new file mode 100644 index 000000000..c457e15fd --- /dev/null +++ b/api/strategies/appleStrategy.test.js @@ -0,0 +1,376 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const jwt = require('jsonwebtoken'); +const { Strategy: AppleStrategy } = require('passport-apple'); +const socialLogin = require('./socialLogin'); +const User = require('~/models/User'); +const { logger } = require('~/config'); +const { createSocialUser, handleExistingUser } = require('./process'); +const { isEnabled } = require('~/server/utils'); +const { findUser } = require('~/models'); + +// Mocking external dependencies +jest.mock('jsonwebtoken'); +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + debug: jest.fn(), + }, +})); +jest.mock('./process', () => ({ + createSocialUser: jest.fn(), + handleExistingUser: jest.fn(), +})); +jest.mock('~/server/utils', () => ({ + isEnabled: jest.fn(), +})); +jest.mock('~/models', () => ({ + findUser: jest.fn(), +})); + +describe('Apple Login Strategy', () => { + let mongoServer; + let appleStrategyInstance; + const OLD_ENV = process.env; + let getProfileDetails; + + // Start and stop in-memory MongoDB + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + process.env = OLD_ENV; + }); + + beforeEach(async () => { + // Reset environment variables + process.env = { ...OLD_ENV }; + process.env.APPLE_CLIENT_ID = 'fake_client_id'; + process.env.APPLE_TEAM_ID = 'fake_team_id'; + process.env.APPLE_CALLBACK_URL = '/auth/apple/callback'; + process.env.DOMAIN_SERVER = 'https://example.com'; + process.env.APPLE_KEY_ID = 'fake_key_id'; + process.env.APPLE_PRIVATE_KEY_PATH = '/path/to/fake/private/key'; + process.env.ALLOW_SOCIAL_REGISTRATION = 'true'; + + // Clear mocks and database + jest.clearAllMocks(); + await User.deleteMany({}); + + // Define getProfileDetails within the test scope + getProfileDetails = ({ idToken, profile }) => { + console.log('getProfileDetails called with idToken:', idToken); + if (!idToken) { + logger.error('idToken is missing'); + throw new Error('idToken is missing'); + } + + const decoded = jwt.decode(idToken); + if (!decoded) { + logger.error('Failed to decode idToken'); + throw new Error('idToken is invalid'); + } + + console.log('Decoded token:', decoded); + + 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 + }; + }; + + // Mock isEnabled based on environment variable + isEnabled.mockImplementation((flag) => { + if (flag === 'true') { return true; } + if (flag === 'false') { return false; } + return false; + }); + + // Initialize the strategy with the mocked getProfileDetails + const appleLogin = socialLogin('apple', getProfileDetails); + appleStrategyInstance = 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, + }, + appleLogin, + ); + }); + + const mockProfile = { + displayName: 'John Doe', + }; + + describe('getProfileDetails', () => { + it('should throw an error if idToken is missing', () => { + expect(() => { + getProfileDetails({ idToken: null, profile: mockProfile }); + }).toThrow('idToken is missing'); + expect(logger.error).toHaveBeenCalledWith('idToken is missing'); + }); + + it('should throw an error if idToken cannot be decoded', () => { + jwt.decode.mockReturnValue(null); + expect(() => { + getProfileDetails({ idToken: 'invalid_id_token', profile: mockProfile }); + }).toThrow('idToken is invalid'); + expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken'); + }); + + it('should extract user details correctly from idToken', () => { + const fakeDecodedToken = { + email: 'john.doe@example.com', + sub: 'apple-sub-1234', + name: { + firstName: 'John', + lastName: 'Doe', + }, + }; + + jwt.decode.mockReturnValue(fakeDecodedToken); + + const profileDetails = getProfileDetails({ + idToken: 'fake_id_token', + profile: mockProfile, + }); + + expect(jwt.decode).toHaveBeenCalledWith('fake_id_token'); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('Decoded Apple JWT'), + ); + expect(profileDetails).toEqual({ + email: 'john.doe@example.com', + id: 'apple-sub-1234', + avatarUrl: null, + username: 'john.doe', + name: 'John Doe', + emailVerified: true, + }); + }); + + it('should handle missing email and use sub for username', () => { + const fakeDecodedToken = { + sub: 'apple-sub-5678', + }; + + jwt.decode.mockReturnValue(fakeDecodedToken); + + const profileDetails = getProfileDetails({ + idToken: 'fake_id_token', + profile: mockProfile, + }); + + expect(profileDetails).toEqual({ + email: undefined, + id: 'apple-sub-5678', + avatarUrl: null, + username: 'user_apple-sub-5678', + name: 'John Doe', + emailVerified: true, + }); + }); + }); + + describe('Strategy verify callback', () => { + const tokenset = { + id_token: 'fake_id_token', + }; + + const decodedToken = { + email: 'jane.doe@example.com', + sub: 'apple-sub-9012', + name: { + firstName: 'Jane', + lastName: 'Doe', + }, + }; + + const fakeAccessToken = 'fake_access_token'; + const fakeRefreshToken = 'fake_refresh_token'; + + beforeEach(() => { + jwt.decode.mockReturnValue(decodedToken); + findUser.mockImplementation(({ email }) => User.findOne({ email })); + }); + + it('should create a new user if one does not exist and registration is allowed', async () => { + // Mock findUser to return null (user does not exist) + findUser.mockResolvedValue(null); + + // Mock createSocialUser to create a user + createSocialUser.mockImplementation(async (userData) => { + const user = new User(userData); + await user.save(); + return user; + }); + + const mockVerifyCallback = jest.fn(); + + // Invoke the verify callback with correct arguments + await new Promise((resolve) => { + appleStrategyInstance._verify( + fakeAccessToken, + fakeRefreshToken, + tokenset.id_token, + mockProfile, + (err, user) => { + mockVerifyCallback(err, user); + resolve(); + }, + ); + }); + + expect(mockVerifyCallback).toHaveBeenCalledWith(null, expect.any(User)); + const user = mockVerifyCallback.mock.calls[0][1]; + expect(user.email).toBe('jane.doe@example.com'); + expect(user.username).toBe('jane.doe'); + expect(user.name).toBe('Jane Doe'); + expect(user.provider).toBe('apple'); + }); + + it('should handle existing user and update avatarUrl', async () => { + // Create an existing user + const existingUser = new User({ + email: 'jane.doe@example.com', + username: 'jane.doe', + name: 'Jane Doe', + provider: 'apple', + providerId: 'apple-sub-9012', + avatarUrl: 'old_avatar.png', + }); + await existingUser.save(); + + // Mock findUser to return the existing user + findUser.mockResolvedValue(existingUser); + + // Mock handleExistingUser to update avatarUrl + handleExistingUser.mockImplementation(async (user, avatarUrl) => { + user.avatarUrl = avatarUrl; + await user.save(); + }); + + const mockVerifyCallback = jest.fn(); + + // Invoke the verify callback with correct arguments + await new Promise((resolve) => { + appleStrategyInstance._verify( + fakeAccessToken, + fakeRefreshToken, + tokenset.id_token, + mockProfile, + (err, user) => { + mockVerifyCallback(err, user); + resolve(); + }, + ); + }); + + expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser); + expect(existingUser.avatarUrl).toBeNull(); // As per getProfileDetails + expect(handleExistingUser).toHaveBeenCalledWith(existingUser, null); + }); + + it('should handle missing idToken gracefully', async () => { + const mockVerifyCallback = jest.fn(); + + // Invoke the verify callback with missing id_token + await new Promise((resolve) => { + appleStrategyInstance._verify( + fakeAccessToken, + fakeRefreshToken, + null, // idToken is missing + mockProfile, + (err, user) => { + mockVerifyCallback(err, user); + resolve(); + }, + ); + }); + + expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined); + expect(mockVerifyCallback.mock.calls[0][0].message).toBe('idToken is missing'); + // Ensure createSocialUser and handleExistingUser were not called + expect(createSocialUser).not.toHaveBeenCalled(); + expect(handleExistingUser).not.toHaveBeenCalled(); + }); + + it('should handle decoding errors gracefully', async () => { + // Simulate decoding failure by returning null + jwt.decode.mockReturnValue(null); + + const mockVerifyCallback = jest.fn(); + + // Invoke the verify callback with correct arguments + await new Promise((resolve) => { + appleStrategyInstance._verify( + fakeAccessToken, + fakeRefreshToken, + tokenset.id_token, + mockProfile, + (err, user) => { + mockVerifyCallback(err, user); + resolve(); + }, + ); + }); + + expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined); + expect(mockVerifyCallback.mock.calls[0][0].message).toBe('idToken is invalid'); + // Ensure createSocialUser and handleExistingUser were not called + expect(createSocialUser).not.toHaveBeenCalled(); + expect(handleExistingUser).not.toHaveBeenCalled(); + // Ensure logger.error was called + expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken'); + }); + + it('should handle errors during user creation', async () => { + // Mock findUser to return null (user does not exist) + findUser.mockResolvedValue(null); + + // Mock createSocialUser to throw an error + createSocialUser.mockImplementation(() => { + throw new Error('Database error'); + }); + + const mockVerifyCallback = jest.fn(); + + // Invoke the verify callback with correct arguments + await new Promise((resolve) => { + appleStrategyInstance._verify( + fakeAccessToken, + fakeRefreshToken, + tokenset.id_token, + mockProfile, + (err, user) => { + mockVerifyCallback(err, user); + resolve(); + }, + ); + }); + + expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined); + expect(mockVerifyCallback.mock.calls[0][0].message).toBe('Database error'); + // Ensure logger.error was called + expect(logger.error).toHaveBeenCalledWith('[appleLogin]', expect.any(Error)); + }); + }); +}); diff --git a/api/strategies/discordStrategy.js b/api/strategies/discordStrategy.js index 02bdfd631..dc7cb05ac 100644 --- a/api/strategies/discordStrategy.js +++ b/api/strategies/discordStrategy.js @@ -1,7 +1,7 @@ const { Strategy: DiscordStrategy } = require('passport-discord'); const socialLogin = require('./socialLogin'); -const getProfileDetails = (profile) => { +const getProfileDetails = ({ profile }) => { let avatarUrl; if (profile.avatar) { const format = profile.avatar.startsWith('a_') ? 'gif' : 'png'; diff --git a/api/strategies/facebookStrategy.js b/api/strategies/facebookStrategy.js index 14c325560..e5d1b054d 100644 --- a/api/strategies/facebookStrategy.js +++ b/api/strategies/facebookStrategy.js @@ -1,7 +1,7 @@ const FacebookStrategy = require('passport-facebook').Strategy; const socialLogin = require('./socialLogin'); -const getProfileDetails = (profile) => ({ +const getProfileDetails = ({ profile }) => ({ email: profile.emails[0]?.value, id: profile.id, avatarUrl: profile.photos[0]?.value, diff --git a/api/strategies/githubStrategy.js b/api/strategies/githubStrategy.js index 8be1b783d..bb3712eeb 100644 --- a/api/strategies/githubStrategy.js +++ b/api/strategies/githubStrategy.js @@ -1,7 +1,7 @@ const { Strategy: GitHubStrategy } = require('passport-github2'); const socialLogin = require('./socialLogin'); -const getProfileDetails = (profile) => ({ +const getProfileDetails = ({ profile }) => ({ email: profile.emails[0].value, id: profile.id, avatarUrl: profile.photos[0].value, diff --git a/api/strategies/googleStrategy.js b/api/strategies/googleStrategy.js index bf8562e18..ab8a26895 100644 --- a/api/strategies/googleStrategy.js +++ b/api/strategies/googleStrategy.js @@ -1,7 +1,7 @@ const { Strategy: GoogleStrategy } = require('passport-google-oauth20'); const socialLogin = require('./socialLogin'); -const getProfileDetails = (profile) => ({ +const getProfileDetails = ({ profile }) => ({ email: profile.emails[0].value, id: profile.id, avatarUrl: profile.photos[0].value, diff --git a/api/strategies/index.js b/api/strategies/index.js index 5ff3b51d9..cac846091 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -1,3 +1,4 @@ +const appleLogin = require('./appleStrategy'); const passportLogin = require('./localStrategy'); const googleLogin = require('./googleStrategy'); const githubLogin = require('./githubStrategy'); @@ -8,6 +9,7 @@ const jwtLogin = require('./jwtStrategy'); const ldapLogin = require('./ldapStrategy'); module.exports = { + appleLogin, passportLogin, googleLogin, githubLogin, diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index a86b17d1c..4b900371d 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -4,9 +4,11 @@ const { findUser } = require('~/models'); const { logger } = require('~/config'); const socialLogin = - (provider, getProfileDetails) => async (accessToken, refreshToken, profile, cb) => { + (provider, getProfileDetails) => async (accessToken, refreshToken, idToken, profile, cb) => { try { - const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails(profile); + const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({ + idToken, profile, + }); const oldUser = await findUser({ email: email.trim() }); const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION); diff --git a/client/src/components/Auth/SocialLoginRender.tsx b/client/src/components/Auth/SocialLoginRender.tsx index 58e68e28b..70c0a65b6 100644 --- a/client/src/components/Auth/SocialLoginRender.tsx +++ b/client/src/components/Auth/SocialLoginRender.tsx @@ -1,4 +1,4 @@ -import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components'; +import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components'; import SocialButton from './SocialButton'; @@ -18,7 +18,7 @@ function SocialLoginRender({ } const providerComponents = { - discord: startupConfig?.discordLoginEnabled && ( + discord: startupConfig.discordLoginEnabled && ( ), - facebook: startupConfig?.facebookLoginEnabled && ( + facebook: startupConfig.facebookLoginEnabled && ( ), - github: startupConfig?.githubLoginEnabled && ( + github: startupConfig.githubLoginEnabled && ( ), - google: startupConfig?.googleLoginEnabled && ( + google: startupConfig.googleLoginEnabled && ( ), - openid: startupConfig?.openidLoginEnabled && ( + apple: startupConfig.appleLoginEnabled && ( + + ), + openid: startupConfig.openidLoginEnabled && ( + + + ); +} \ No newline at end of file diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index 3a2929e64..745c5210b 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -22,6 +22,7 @@ export { default as FacebookIcon } from './FacebookIcon'; export { default as OpenIDIcon } from './OpenIDIcon'; export { default as GithubIcon } from './GithubIcon'; export { default as DiscordIcon } from './DiscordIcon'; +export { default as AppleIcon } from './AppleIcon'; export { default as AnthropicIcon } from './AnthropicIcon'; export { default as SendIcon } from './SendIcon'; export { default as LinkIcon } from './LinkIcon'; diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 30a578f96..cb7dcc9e3 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -472,6 +472,7 @@ export default { com_auth_facebook_login: 'Continue with Facebook', com_auth_github_login: 'Continue with Github', com_auth_discord_login: 'Continue with Discord', + com_auth_apple_login: 'Sign in with Apple', com_auth_email: 'Email', com_auth_email_required: 'Email is required', com_auth_email_min_length: 'Email must be at least 6 characters', diff --git a/librechat.example.yaml b/librechat.example.yaml index 7b2c0c135..e49f9b37b 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -69,7 +69,7 @@ interface: # Example Registration Object Structure (optional) registration: - socialLogins: ['github', 'google', 'discord', 'openid', 'facebook'] + socialLogins: ['github', 'google', 'discord', 'openid', 'facebook', 'apple'] # allowedDomains: # - "gmail.com" diff --git a/package-lock.json b/package-lock.json index a4ee58a35..d67fe1f95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,6 +98,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", @@ -28016,6 +28017,16 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-apple": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/passport-apple/-/passport-apple-2.0.2.tgz", + "integrity": "sha512-JRXomYvirWeIq11pa/SwhXXxekFWoukMcQu45BDl3Kw5WobtWF0iw99vpkBwPEpdaou0DDSq4udxR34T6eZkdw==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-oauth2": "^1.6.1" + } + }, "node_modules/passport-custom": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 102f5c070..5ae917138 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -479,6 +479,7 @@ export type TStartupConfig = { githubLoginEnabled: boolean; googleLoginEnabled: boolean; openidLoginEnabled: boolean; + appleLoginEnabled: boolean; openidLabel: string; openidImageUrl: string; /** LDAP Auth Configuration */