🍎 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:
Ruben Talstra 2025-01-31 15:49:09 +01:00 committed by GitHub
parent 1c459ed3af
commit e1a6268904
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 545 additions and 13 deletions

View file

@ -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=

View file

@ -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
@ -111,6 +112,11 @@ const userSchema = mongoose.Schema(
unique: true,
sparse: true,
},
appleId: {
type: String,
unique: true,
sparse: true,
},
plugins: {
type: Array,
default: [],

View file

@ -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",

View file

@ -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 &&

View file

@ -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;

View file

@ -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 &&

View 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,
);

View 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));
});
});
});

View file

@ -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';

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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 && (
<SocialButton
key="discord"
enabled={startupConfig.discordLoginEnabled}
@ -29,7 +29,7 @@ function SocialLoginRender({
id="discord"
/>
),
facebook: startupConfig?.facebookLoginEnabled && (
facebook: startupConfig.facebookLoginEnabled && (
<SocialButton
key="facebook"
enabled={startupConfig.facebookLoginEnabled}
@ -40,7 +40,7 @@ function SocialLoginRender({
id="facebook"
/>
),
github: startupConfig?.githubLoginEnabled && (
github: startupConfig.githubLoginEnabled && (
<SocialButton
key="github"
enabled={startupConfig.githubLoginEnabled}
@ -51,7 +51,7 @@ function SocialLoginRender({
id="github"
/>
),
google: startupConfig?.googleLoginEnabled && (
google: startupConfig.googleLoginEnabled && (
<SocialButton
key="google"
enabled={startupConfig.googleLoginEnabled}
@ -62,7 +62,18 @@ function SocialLoginRender({
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
key="openid"
enabled={startupConfig.openidLoginEnabled}

View 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>
);
}

View file

@ -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';

View file

@ -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',

View file

@ -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"

11
package-lock.json generated
View file

@ -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",

View file

@ -479,6 +479,7 @@ export type TStartupConfig = {
githubLoginEnabled: boolean;
googleLoginEnabled: boolean;
openidLoginEnabled: boolean;
appleLoginEnabled: boolean;
openidLabel: string;
openidImageUrl: string;
/** LDAP Auth Configuration */