diff --git a/.env.example b/.env.example index fe924ba4d..e1d5a57a7 100644 --- a/.env.example +++ b/.env.example @@ -218,6 +218,14 @@ OPENID_IMAGE_URL= # Delay is in millisecond e.g. 7 days is 1000*60*60*24*7 SESSION_EXPIRY=(1000 * 60 * 60 * 24) * 7 +# Github: +# Get the Client ID and Secret from your Github Application +# Add your Github Client ID and Client Secret here: + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL=/oauth/github/callback + ########################### # Application Domains ########################### diff --git a/api/models/User.js b/api/models/User.js index b7f42023e..011291c34 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -70,6 +70,11 @@ const userSchema = mongoose.Schema( unique: true, sparse: true }, + githubId: { + type: String, + unique: true, + sparse: true + }, plugins: { type: Array, default: [] diff --git a/api/server/index.js b/api/server/index.js index ec203703e..947edfdb8 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -42,6 +42,9 @@ config.validate(); // Validate the config if (process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET) { require('../strategies/facebookStrategy'); } + if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + require('../strategies/githubStrategy'); + } if (process.env.OPENID_CLIENT_ID && process.env.OPENID_CLIENT_SECRET && process.env.OPENID_ISSUER && process.env.OPENID_SCOPE && process.env.OPENID_SESSION_SECRET) { diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 4bc751d27..928fc2208 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -14,6 +14,8 @@ afterEach(() => { delete process.env.OPENID_SESSION_SECRET; delete process.env.OPENID_BUTTON_LABEL; delete process.env.OPENID_AUTH_URL; + delete process.env.GITHUB_CLIENT_ID; + delete process.env.GITHUB_CLIENT_SECRET; delete process.env.DOMAIN_SERVER; delete process.env.ALLOW_REGISTRATION; }); @@ -32,6 +34,8 @@ describe.skip('GET /', () => { process.env.OPENID_SESSION_SECRET= 'Test Secret'; process.env.OPENID_BUTTON_LABEL= 'Test OpenID'; process.env.OPENID_AUTH_URL= 'http://test-server.com'; + process.env.GITHUB_CLIENT_ID = 'Test Github client Id'; + process.env.GITHUB_CLIENT_SECRET= 'Test Github client Secret'; process.env.DOMAIN_SERVER = 'http://test-server.com'; process.env.ALLOW_REGISTRATION = 'true'; @@ -44,6 +48,7 @@ describe.skip('GET /', () => { openidLoginEnabled: true, openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', + githubLoginEnabled: true, serverDomain: 'http://test-server.com', registrationEnabled: 'true', }); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 99e3ce613..115fffa35 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -11,10 +11,11 @@ router.get('/', async function (req, res) { && !!process.env.OPENID_SESSION_SECRET; const openidLabel = process.env.OPENID_BUTTON_LABEL || 'Login with OpenID'; const openidImageUrl = process.env.OPENID_IMAGE_URL; + const githubLoginEnabled = !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET; const serverDomain = process.env.DOMAIN_SERVER || 'http://localhost:3080'; const registrationEnabled = process.env.ALLOW_REGISTRATION === 'true'; - return res.status(200).send({appTitle, googleLoginEnabled, openidLoginEnabled, openidLabel, openidImageUrl, serverDomain, registrationEnabled}); + return res.status(200).send({appTitle, googleLoginEnabled, openidLoginEnabled, openidLabel, openidImageUrl, githubLoginEnabled, serverDomain, registrationEnabled}); } catch (err) { console.error(err); return res.status(500).send({error: err.message}); diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 0a1893457..8289232e2 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -87,4 +87,32 @@ router.get( } ); + +router.get( + '/github', + passport.authenticate('github', { + scope: ['user:email', 'read:user'], + session: false + }) +); + +router.get( + '/github/callback', + passport.authenticate('github', { + failureRedirect: `${domains.client}/login`, + failureMessage: true, + session: false, + scope: ['user:email', 'read:user'] + }), + (req, res) => { + const token = req.user.generateToken(); + res.cookie('token', token, { + expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)), + httpOnly: false, + secure: isProduction + }); + res.redirect(domains.client); + } +); + module.exports = router; diff --git a/api/strategies/githubStrategy.js b/api/strategies/githubStrategy.js new file mode 100644 index 000000000..d377b6183 --- /dev/null +++ b/api/strategies/githubStrategy.js @@ -0,0 +1,47 @@ +const passport = require('passport'); +const { Strategy: GitHubStrategy } = require('passport-github2'); +const config = require('../../config/loader'); +const domains = config.domains; + +const User = require('../models/User'); + +// GitHub strategy +const githubLogin = new GitHubStrategy( + { + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL: `${domains.server}${process.env.GITHUB_CALLBACK_URL}`, + proxy: false, + scope: ['user:email'] // Request email scope + }, + async (accessToken, refreshToken, profile, cb) => { + try { + let email; + if (profile.emails && profile.emails.length > 0) { + email = profile.emails[0].value; + } + + const oldUser = await User.findOne({ email }); + if (oldUser) { + return cb(null, oldUser); + } + + const newUser = await new User({ + provider: 'github', + githubId: profile.id, + username: profile.username, + email, + emailVerified: profile.emails[0].verified, + name: profile.displayName, + avatar: profile.photos[0].value + }).save(); + + cb(null, newUser); + } catch (err) { + console.error(err); + cb(err); + } + } +); + +passport.use(githubLogin); diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index 2675f12dd..5d2b2625d 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -102,9 +102,38 @@ function Login() { )} + {startupConfig?.githubLoginEnabled && ( + <> +
+
Or
+
+
+ + + + +

Login with GitHub

+
+
+ +)} + ); } + export default Login; diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index 1976b35fa..29cf776a1 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -340,6 +340,34 @@ function Registration() { )} + {startupConfig?.githubLoginEnabled && ( + <> +
+
Or
+
+
+ + + + +

Login with GitHub

+
+
+ +)} + ); diff --git a/client/src/components/Auth/__tests__/Login.spec.tsx b/client/src/components/Auth/__tests__/Login.spec.tsx index 21c9b47ac..cf9b0fa41 100644 --- a/client/src/components/Auth/__tests__/Login.spec.tsx +++ b/client/src/components/Auth/__tests__/Login.spec.tsx @@ -26,6 +26,7 @@ const setup = ({ openidLoginEnabled: true, openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', + githubLoginEnabled: true, registrationEnabled: true, serverDomain: 'mock-server' } diff --git a/client/src/components/Auth/__tests__/Registration.spec.tsx b/client/src/components/Auth/__tests__/Registration.spec.tsx index f8a948d7e..6a373be68 100644 --- a/client/src/components/Auth/__tests__/Registration.spec.tsx +++ b/client/src/components/Auth/__tests__/Registration.spec.tsx @@ -26,6 +26,7 @@ const setup = ({ openidLoginEnabled: true, openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', + githubLoginEnabled: true, registrationEnabled: true, serverDomain: 'mock-server' } diff --git a/client/src/data-provider/types.ts b/client/src/data-provider/types.ts index 49832624b..e2cc315c9 100644 --- a/client/src/data-provider/types.ts +++ b/client/src/data-provider/types.ts @@ -243,6 +243,7 @@ export type TStartupConfig = { openidLoginEnabled: boolean; openidLabel: string; openidImageUrl: string; + githubLoginEnabled: boolean; serverDomain: string; registrationEnabled: boolean; } diff --git a/docs/features/user_auth_system.md b/docs/features/user_auth_system.md index b34867bcb..12298c188 100644 --- a/docs/features/user_auth_system.md +++ b/docs/features/user_auth_system.md @@ -68,6 +68,19 @@ OPENID_CALLBACK_URL=/oauth/openid/callback --- +## How to Set Up Github Authentication + +1. Go to your [Github Developer settings](https://github.com/settings/apps) +2. Create a new Github app +3. Give it a GitHub App name and set in the Homepage URL your [DOMAIN_CLIENT](https://github.com/danny-avila/LibreChat/blob/main/.env.example#L219) (example: http://localhost:3080) +4. Add a callback URL and set it as "[Your DOMAIN_CLIENT](https://github.com/danny-avila/LibreChat/blob/main/.env.example#L219)/oauth/github/callback" (example: http://localhost:3080/oauth/github/callback) +5. Remove the Active checkbox in the Webhook section +6. Save changes and generate a Client Secret +7. In the Permissions & events tab select, open the Account Permissions and set Email addresses to Read-only +8. Put the Client ID and Client Secret in the .env file +9. Save the .env file + +--- ## **Email and Password Reset** Most of the code is in place for sending password reset emails, but is not yet feature-complete as I have not setup an email server to test it. Currently, submitting a password reset request will then display a link with the one-time reset token that can then be used to reset the password. Understanding that this is a considerable security hazard, email integration will be included in the next release. @@ -83,4 +96,4 @@ If you previously implemented your own user system using the original scaffoldin ### For user updating from an older version of the app: When the first account is registered, the application will automatically migrate any conversations and presets that you created before the user system was implemented to that account. -if you use login for the first time with a social login account (eg. Google, facebook, etc.), the conversations and presets that you created before the user system was implemented will NOT be migrated to that account. \ No newline at end of file +if you use login for the first time with a social login account (eg. Google, facebook, etc.), the conversations and presets that you created before the user system was implemented will NOT be migrated to that account. diff --git a/package-lock.json b/package-lock.json index 4abdf9ca5..8f5a2dafe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,10 @@ "client" ], "dependencies": { - "langchain": "^0.0.91" + "axios": "^1.4.0", + "langchain": "^0.0.91", + "passport": "^0.6.0", + "passport-github2": "^0.1.12" }, "devDependencies": { "@playwright/test": "^1.32.1", @@ -20320,6 +20323,17 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", @@ -40406,6 +40420,14 @@ "passport-oauth2": "1.x.x" } }, + "passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, "passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", diff --git a/package.json b/package.json index 2f75434fe..b99ffbb12 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,10 @@ }, "homepage": "https://github.com/danny-avila/LibreChat#readme", "dependencies": { - "langchain": "^0.0.91" + "axios": "^1.4.0", + "langchain": "^0.0.91", + "passport": "^0.6.0", + "passport-github2": "^0.1.12" }, "devDependencies": { "@playwright/test": "^1.32.1",