diff --git a/.env.example b/.env.example index 88c5e462a9..4f6fbe0916 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 57b96342db..f586553367 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 caeccc5bf4..26fd625ea6 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 f6669169ac..705a1d3cb1 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 dd4370afe6..046370798b 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 6da71f908f..ec3a73e0ac 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 0000000000..a45f10fc62 --- /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 0000000000..c457e15fdc --- /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 02bdfd631b..dc7cb05ac6 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 14c325560d..e5d1b054db 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 8be1b783d7..bb3712eeba 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 bf8562e183..ab8a268953 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 5ff3b51d92..cac8460914 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 a86b17d1ca..4b900371d1 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 58e68e28bf..70c0a65b63 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 3a2929e64c..745c5210bd 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 30a578f96e..cb7dcc9e3d 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 7b2c0c1352..e49f9b37b3 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 a4ee58a353..d67fe1f95d 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 102f5c070a..5ae917138b 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 */