mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🍎 feat: Apple auth (#5473)
* 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
This commit is contained in:
parent
1c459ed3af
commit
e1a6268904
21 changed files with 545 additions and 13 deletions
|
@ -391,6 +391,13 @@ GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_CALLBACK_URL=/oauth/google/callback
|
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
|
||||||
OPENID_CLIENT_ID=
|
OPENID_CLIENT_ID=
|
||||||
OPENID_CLIENT_SECRET=
|
OPENID_CLIENT_SECRET=
|
||||||
|
|
|
@ -23,6 +23,7 @@ const { SystemRoles } = require('librechat-data-provider');
|
||||||
* @property {string} [ldapId] - Optional LDAP ID for the user
|
* @property {string} [ldapId] - Optional LDAP ID for the user
|
||||||
* @property {string} [githubId] - Optional GitHub ID for the user
|
* @property {string} [githubId] - Optional GitHub ID for the user
|
||||||
* @property {string} [discordId] - Optional Discord 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} [plugins=[]] - List of plugins used by the user
|
||||||
* @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens
|
* @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens
|
||||||
* @property {Date} [expiresAt] - Optional expiration date of the file
|
* @property {Date} [expiresAt] - Optional expiration date of the file
|
||||||
|
@ -111,6 +112,11 @@ const userSchema = mongoose.Schema(
|
||||||
unique: true,
|
unique: true,
|
||||||
sparse: true,
|
sparse: true,
|
||||||
},
|
},
|
||||||
|
appleId: {
|
||||||
|
type: String,
|
||||||
|
unique: true,
|
||||||
|
sparse: true,
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: [],
|
default: [],
|
||||||
|
|
|
@ -89,6 +89,7 @@
|
||||||
"openai-chat-tokens": "^0.2.8",
|
"openai-chat-tokens": "^0.2.8",
|
||||||
"openid-client": "^5.4.2",
|
"openid-client": "^5.4.2",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
|
"passport-apple": "^2.0.2",
|
||||||
"passport-custom": "^1.1.1",
|
"passport-custom": "^1.1.1",
|
||||||
"passport-discord": "^0.1.4",
|
"passport-discord": "^0.1.4",
|
||||||
"passport-facebook": "^3.0.0",
|
"passport-facebook": "^3.0.0",
|
||||||
|
|
|
@ -46,6 +46,11 @@ router.get('/', async function (req, res) {
|
||||||
!!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
|
!!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
|
||||||
githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET,
|
githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET,
|
||||||
googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_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:
|
openidLoginEnabled:
|
||||||
!!process.env.OPENID_CLIENT_ID &&
|
!!process.env.OPENID_CLIENT_ID &&
|
||||||
!!process.env.OPENID_CLIENT_SECRET &&
|
!!process.env.OPENID_CLIENT_SECRET &&
|
||||||
|
|
|
@ -56,6 +56,9 @@ router.get(
|
||||||
oauthHandler,
|
oauthHandler,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facebook Routes
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/facebook',
|
'/facebook',
|
||||||
passport.authenticate('facebook', {
|
passport.authenticate('facebook', {
|
||||||
|
@ -77,6 +80,9 @@ router.get(
|
||||||
oauthHandler,
|
oauthHandler,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenID Routes
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/openid',
|
'/openid',
|
||||||
passport.authenticate('openid', {
|
passport.authenticate('openid', {
|
||||||
|
@ -94,6 +100,9 @@ router.get(
|
||||||
oauthHandler,
|
oauthHandler,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Routes
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/github',
|
'/github',
|
||||||
passport.authenticate('github', {
|
passport.authenticate('github', {
|
||||||
|
@ -112,6 +121,10 @@ router.get(
|
||||||
}),
|
}),
|
||||||
oauthHandler,
|
oauthHandler,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord Routes
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/discord',
|
'/discord',
|
||||||
passport.authenticate('discord', {
|
passport.authenticate('discord', {
|
||||||
|
@ -131,4 +144,24 @@ router.get(
|
||||||
oauthHandler,
|
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;
|
module.exports = router;
|
||||||
|
|
|
@ -9,6 +9,7 @@ const {
|
||||||
githubLogin,
|
githubLogin,
|
||||||
discordLogin,
|
discordLogin,
|
||||||
facebookLogin,
|
facebookLogin,
|
||||||
|
appleLogin,
|
||||||
} = require('~/strategies');
|
} = require('~/strategies');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
@ -30,6 +31,9 @@ const configureSocialLogins = (app) => {
|
||||||
if (process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET) {
|
if (process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET) {
|
||||||
passport.use(discordLogin());
|
passport.use(discordLogin());
|
||||||
}
|
}
|
||||||
|
if (process.env.APPLE_CLIENT_ID && process.env.APPLE_PRIVATE_KEY_PATH) {
|
||||||
|
passport.use(appleLogin());
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
process.env.OPENID_CLIENT_ID &&
|
process.env.OPENID_CLIENT_ID &&
|
||||||
process.env.OPENID_CLIENT_SECRET &&
|
process.env.OPENID_CLIENT_SECRET &&
|
||||||
|
|
53
api/strategies/appleStrategy.js
Normal file
53
api/strategies/appleStrategy.js
Normal file
|
@ -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,
|
||||||
|
);
|
376
api/strategies/appleStrategy.test.js
Normal file
376
api/strategies/appleStrategy.test.js
Normal file
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,7 +1,7 @@
|
||||||
const { Strategy: DiscordStrategy } = require('passport-discord');
|
const { Strategy: DiscordStrategy } = require('passport-discord');
|
||||||
const socialLogin = require('./socialLogin');
|
const socialLogin = require('./socialLogin');
|
||||||
|
|
||||||
const getProfileDetails = (profile) => {
|
const getProfileDetails = ({ profile }) => {
|
||||||
let avatarUrl;
|
let avatarUrl;
|
||||||
if (profile.avatar) {
|
if (profile.avatar) {
|
||||||
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
|
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
const FacebookStrategy = require('passport-facebook').Strategy;
|
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||||
const socialLogin = require('./socialLogin');
|
const socialLogin = require('./socialLogin');
|
||||||
|
|
||||||
const getProfileDetails = (profile) => ({
|
const getProfileDetails = ({ profile }) => ({
|
||||||
email: profile.emails[0]?.value,
|
email: profile.emails[0]?.value,
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
avatarUrl: profile.photos[0]?.value,
|
avatarUrl: profile.photos[0]?.value,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
const { Strategy: GitHubStrategy } = require('passport-github2');
|
const { Strategy: GitHubStrategy } = require('passport-github2');
|
||||||
const socialLogin = require('./socialLogin');
|
const socialLogin = require('./socialLogin');
|
||||||
|
|
||||||
const getProfileDetails = (profile) => ({
|
const getProfileDetails = ({ profile }) => ({
|
||||||
email: profile.emails[0].value,
|
email: profile.emails[0].value,
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
avatarUrl: profile.photos[0].value,
|
avatarUrl: profile.photos[0].value,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
|
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
|
||||||
const socialLogin = require('./socialLogin');
|
const socialLogin = require('./socialLogin');
|
||||||
|
|
||||||
const getProfileDetails = (profile) => ({
|
const getProfileDetails = ({ profile }) => ({
|
||||||
email: profile.emails[0].value,
|
email: profile.emails[0].value,
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
avatarUrl: profile.photos[0].value,
|
avatarUrl: profile.photos[0].value,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
const appleLogin = require('./appleStrategy');
|
||||||
const passportLogin = require('./localStrategy');
|
const passportLogin = require('./localStrategy');
|
||||||
const googleLogin = require('./googleStrategy');
|
const googleLogin = require('./googleStrategy');
|
||||||
const githubLogin = require('./githubStrategy');
|
const githubLogin = require('./githubStrategy');
|
||||||
|
@ -8,6 +9,7 @@ const jwtLogin = require('./jwtStrategy');
|
||||||
const ldapLogin = require('./ldapStrategy');
|
const ldapLogin = require('./ldapStrategy');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
appleLogin,
|
||||||
passportLogin,
|
passportLogin,
|
||||||
googleLogin,
|
googleLogin,
|
||||||
githubLogin,
|
githubLogin,
|
||||||
|
|
|
@ -4,9 +4,11 @@ const { findUser } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const socialLogin =
|
const socialLogin =
|
||||||
(provider, getProfileDetails) => async (accessToken, refreshToken, profile, cb) => {
|
(provider, getProfileDetails) => async (accessToken, refreshToken, idToken, profile, cb) => {
|
||||||
try {
|
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 oldUser = await findUser({ email: email.trim() });
|
||||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||||
|
|
|
@ -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';
|
import SocialButton from './SocialButton';
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ function SocialLoginRender({
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerComponents = {
|
const providerComponents = {
|
||||||
discord: startupConfig?.discordLoginEnabled && (
|
discord: startupConfig.discordLoginEnabled && (
|
||||||
<SocialButton
|
<SocialButton
|
||||||
key="discord"
|
key="discord"
|
||||||
enabled={startupConfig.discordLoginEnabled}
|
enabled={startupConfig.discordLoginEnabled}
|
||||||
|
@ -29,7 +29,7 @@ function SocialLoginRender({
|
||||||
id="discord"
|
id="discord"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
facebook: startupConfig?.facebookLoginEnabled && (
|
facebook: startupConfig.facebookLoginEnabled && (
|
||||||
<SocialButton
|
<SocialButton
|
||||||
key="facebook"
|
key="facebook"
|
||||||
enabled={startupConfig.facebookLoginEnabled}
|
enabled={startupConfig.facebookLoginEnabled}
|
||||||
|
@ -40,7 +40,7 @@ function SocialLoginRender({
|
||||||
id="facebook"
|
id="facebook"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
github: startupConfig?.githubLoginEnabled && (
|
github: startupConfig.githubLoginEnabled && (
|
||||||
<SocialButton
|
<SocialButton
|
||||||
key="github"
|
key="github"
|
||||||
enabled={startupConfig.githubLoginEnabled}
|
enabled={startupConfig.githubLoginEnabled}
|
||||||
|
@ -51,7 +51,7 @@ function SocialLoginRender({
|
||||||
id="github"
|
id="github"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
google: startupConfig?.googleLoginEnabled && (
|
google: startupConfig.googleLoginEnabled && (
|
||||||
<SocialButton
|
<SocialButton
|
||||||
key="google"
|
key="google"
|
||||||
enabled={startupConfig.googleLoginEnabled}
|
enabled={startupConfig.googleLoginEnabled}
|
||||||
|
@ -62,7 +62,18 @@ function SocialLoginRender({
|
||||||
id="google"
|
id="google"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
openid: startupConfig?.openidLoginEnabled && (
|
apple: startupConfig.appleLoginEnabled && (
|
||||||
|
<SocialButton
|
||||||
|
key="apple"
|
||||||
|
enabled={startupConfig.appleLoginEnabled}
|
||||||
|
serverDomain={startupConfig.serverDomain}
|
||||||
|
oauthPath="apple"
|
||||||
|
Icon={AppleIcon}
|
||||||
|
label={localize('com_auth_apple_login')}
|
||||||
|
id="apple"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
openid: startupConfig.openidLoginEnabled && (
|
||||||
<SocialButton
|
<SocialButton
|
||||||
key="openid"
|
key="openid"
|
||||||
enabled={startupConfig.openidLoginEnabled}
|
enabled={startupConfig.openidLoginEnabled}
|
||||||
|
|
18
client/src/components/svg/AppleIcon.tsx
Normal file
18
client/src/components/svg/AppleIcon.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function AppleIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlSpace="preserve"
|
||||||
|
viewBox="0 0 814 1000"
|
||||||
|
id="apple"
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ export { default as FacebookIcon } from './FacebookIcon';
|
||||||
export { default as OpenIDIcon } from './OpenIDIcon';
|
export { default as OpenIDIcon } from './OpenIDIcon';
|
||||||
export { default as GithubIcon } from './GithubIcon';
|
export { default as GithubIcon } from './GithubIcon';
|
||||||
export { default as DiscordIcon } from './DiscordIcon';
|
export { default as DiscordIcon } from './DiscordIcon';
|
||||||
|
export { default as AppleIcon } from './AppleIcon';
|
||||||
export { default as AnthropicIcon } from './AnthropicIcon';
|
export { default as AnthropicIcon } from './AnthropicIcon';
|
||||||
export { default as SendIcon } from './SendIcon';
|
export { default as SendIcon } from './SendIcon';
|
||||||
export { default as LinkIcon } from './LinkIcon';
|
export { default as LinkIcon } from './LinkIcon';
|
||||||
|
|
|
@ -472,6 +472,7 @@ export default {
|
||||||
com_auth_facebook_login: 'Continue with Facebook',
|
com_auth_facebook_login: 'Continue with Facebook',
|
||||||
com_auth_github_login: 'Continue with Github',
|
com_auth_github_login: 'Continue with Github',
|
||||||
com_auth_discord_login: 'Continue with Discord',
|
com_auth_discord_login: 'Continue with Discord',
|
||||||
|
com_auth_apple_login: 'Sign in with Apple',
|
||||||
com_auth_email: 'Email',
|
com_auth_email: 'Email',
|
||||||
com_auth_email_required: 'Email is required',
|
com_auth_email_required: 'Email is required',
|
||||||
com_auth_email_min_length: 'Email must be at least 6 characters',
|
com_auth_email_min_length: 'Email must be at least 6 characters',
|
||||||
|
|
|
@ -69,7 +69,7 @@ interface:
|
||||||
|
|
||||||
# Example Registration Object Structure (optional)
|
# Example Registration Object Structure (optional)
|
||||||
registration:
|
registration:
|
||||||
socialLogins: ['github', 'google', 'discord', 'openid', 'facebook']
|
socialLogins: ['github', 'google', 'discord', 'openid', 'facebook', 'apple']
|
||||||
# allowedDomains:
|
# allowedDomains:
|
||||||
# - "gmail.com"
|
# - "gmail.com"
|
||||||
|
|
||||||
|
|
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -98,6 +98,7 @@
|
||||||
"openai-chat-tokens": "^0.2.8",
|
"openai-chat-tokens": "^0.2.8",
|
||||||
"openid-client": "^5.4.2",
|
"openid-client": "^5.4.2",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
|
"passport-apple": "^2.0.2",
|
||||||
"passport-custom": "^1.1.1",
|
"passport-custom": "^1.1.1",
|
||||||
"passport-discord": "^0.1.4",
|
"passport-discord": "^0.1.4",
|
||||||
"passport-facebook": "^3.0.0",
|
"passport-facebook": "^3.0.0",
|
||||||
|
@ -28016,6 +28017,16 @@
|
||||||
"url": "https://github.com/sponsors/jaredhanson"
|
"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": {
|
"node_modules/passport-custom": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
|
||||||
|
|
|
@ -479,6 +479,7 @@ export type TStartupConfig = {
|
||||||
githubLoginEnabled: boolean;
|
githubLoginEnabled: boolean;
|
||||||
googleLoginEnabled: boolean;
|
googleLoginEnabled: boolean;
|
||||||
openidLoginEnabled: boolean;
|
openidLoginEnabled: boolean;
|
||||||
|
appleLoginEnabled: boolean;
|
||||||
openidLabel: string;
|
openidLabel: string;
|
||||||
openidImageUrl: string;
|
openidImageUrl: string;
|
||||||
/** LDAP Auth Configuration */
|
/** LDAP Auth Configuration */
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue