From b6ff45c7153750c401e92113ae27c5ded3aa77ec Mon Sep 17 00:00:00 2001 From: Dileepa Sandaruwan Mabulage <99941065+dsmabulage@users.noreply.github.com> Date: Sun, 14 Jul 2024 14:20:51 +0530 Subject: [PATCH] Implement linkedin login (#131) Co-authored-by: Dileepa Mabulage --- .env.example | 3 + README.md | 24 ++++- package-lock.json | 22 ++++ package.json | 2 + src/app.ts | 3 +- src/configs/envConfig.ts | 3 + .../{passport.ts => google-passport.ts} | 19 ++-- src/configs/linkedin-passport.ts | 101 ++++++++++++++++++ src/controllers/auth.controller.ts | 24 +++++ src/routes/auth/auth.route.ts | 17 ++- src/services/auth.service.ts | 28 ++--- src/types.ts | 16 +++ 12 files changed, 236 insertions(+), 26 deletions(-) rename src/configs/{passport.ts => google-passport.ts} (82%) create mode 100644 src/configs/linkedin-passport.ts diff --git a/.env.example b/.env.example index fa617df9..8eac7fbd 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,6 @@ CLIENT_URL=http://localhost:5173 IMG_HOST=http://localhost:${SERVER_PORT} SMTP_MAIL=your_smtp_mail SMTP_PASSWORD=your_smtp_password +LINKEDIN_CLIENT_ID=your_linkedin_client_id +LINKEDIN_CLIENT_SECRET=your_linkedin_client_secret +LINKEDIN_REDIRECT_URL=http://localhost:${SERVER_PORT}/api/auth/linkedin/callback diff --git a/README.md b/README.md index 3bf0284a..187a38ef 100644 --- a/README.md +++ b/README.md @@ -250,4 +250,26 @@ process.env.GOOGLE_CLIENT_SECRET = 'your-client-secret'; process.env.GOOGLE_REDIRECT_URL = 'your-redirect-uri'; -We appreciate your interest in ScholarX. Happy contributing! If you have any questions or need assistance, please don't hesitate to reach out to us. \ No newline at end of file +We appreciate your interest in ScholarX. Happy contributing! If you have any questions or need assistance, please don't hesitate to reach out to us. + +## Setting up LinkedIn Authentication + +1. Create LinkedIn page with the mandatory information. + +2. Navigate to https://developer.linkedin.com/ + +3. Select "Create App": + - Add App name. + - Search for the LinkedIn page that was previously created. + - Upload an image as a Logo. + - Create the App. + +4. In Products section select `Share on LinkedIn` and `Sign In with LinkedIn using OpenID Connect` request access. + +5. In Auth section edit the `Authorized redirect URLs for your app` and add the redirect url. `http://localhost:3000/api/auth/linkedin/callback` + +6. Copy Client Id and Client Secret from the Auth Section. + +6. In setting section verify the LinkedIn Page and generate URL. + +7. Verify it from your account. diff --git a/package-lock.json b/package-lock.json index 73437cd7..6c9a6966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", + "passport-linkedin-oauth2": "github:auth0/passport-linkedin-oauth2#v3.0.0", "pg": "^8.10.0", "reflect-metadata": "^0.1.13", "ts-node": "^10.9.1", @@ -43,6 +44,7 @@ "@types/passport": "^1.0.12", "@types/passport-google-oauth20": "^2.0.14", "@types/passport-jwt": "^3.0.9", + "@types/passport-linkedin-oauth2": "^1.5.6", "@types/pg": "^8.10.1", "@types/prettier": "^2.7.2", "@types/supertest": "^2.0.12", @@ -2281,6 +2283,16 @@ "@types/passport-strategy": "*" } }, + "node_modules/@types/passport-linkedin-oauth2": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/passport-linkedin-oauth2/-/passport-linkedin-oauth2-1.5.6.tgz", + "integrity": "sha512-LlIwa+GGK8KoUHDxxwO2+5uqB6YmIHysqdLwpn+YJsjfmqFdAH+4YjhXO7riYwfYcpEr/pI+dSEDlwF0Xt+qhg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/passport-oauth2": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.15.tgz", @@ -8655,6 +8667,16 @@ "passport-strategy": "^1.0.0" } }, + "node_modules/passport-linkedin-oauth2": { + "version": "3.0.0", + "resolved": "git+ssh://git@github.com/auth0/passport-linkedin-oauth2.git#8fe01d1c50124b25b6eaca3950151e6aceb800fe", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/passport-oauth2": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", diff --git a/package.json b/package.json index 90a15d39..3a477c7c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", + "passport-linkedin-oauth2": "github:auth0/passport-linkedin-oauth2#v3.0.0", "pg": "^8.10.0", "reflect-metadata": "^0.1.13", "ts-node": "^10.9.1", @@ -53,6 +54,7 @@ "@types/passport": "^1.0.12", "@types/passport-google-oauth20": "^2.0.14", "@types/passport-jwt": "^3.0.9", + "@types/passport-linkedin-oauth2": "^1.5.6", "@types/pg": "^8.10.1", "@types/prettier": "^2.7.2", "@types/supertest": "^2.0.12", diff --git a/src/app.ts b/src/app.ts index 71932ce0..eb92fff2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,8 @@ import adminRouter from './routes/admin/admin.route' import mentorRouter from './routes/mentor/mentor.route' import categoryRouter from './routes/category/category.route' import passport from 'passport' -import './configs/passport' +import './configs/google-passport' +import './configs/linkedin-passport' import { CLIENT_URL } from './configs/envConfig' import cookieParser from 'cookie-parser' import menteeRouter from './routes/mentee/mentee.route' diff --git a/src/configs/envConfig.ts b/src/configs/envConfig.ts index fc8ea9f2..451eebde 100644 --- a/src/configs/envConfig.ts +++ b/src/configs/envConfig.ts @@ -16,3 +16,6 @@ export const CLIENT_URL = process.env.CLIENT_URL ?? '' export const IMG_HOST = process.env.IMG_HOST ?? '' export const SMTP_MAIL = process.env.SMTP_MAIL ?? '' export const SMTP_PASS = process.env.SMTP_PASS ?? '' +export const LINKEDIN_CLIENT_ID = process.env.LINKEDIN_CLIENT_ID ?? '' +export const LINKEDIN_CLIENT_SECRET = process.env.LINKEDIN_CLIENT_SECRET ?? '' +export const LINKEDIN_REDIRECT_URL = process.env.LINKEDIN_REDIRECT_URL ?? '' diff --git a/src/configs/passport.ts b/src/configs/google-passport.ts similarity index 82% rename from src/configs/passport.ts rename to src/configs/google-passport.ts index 361b4570..8ed3c011 100644 --- a/src/configs/passport.ts +++ b/src/configs/google-passport.ts @@ -1,18 +1,18 @@ +import type { Request } from 'express' import passport from 'passport' import { Strategy as JwtStrategy } from 'passport-jwt' -import { dataSource } from './dbConfig' import Profile from '../entities/profile.entity' +import { dataSource } from './dbConfig' import { - JWT_SECRET, GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URL, - GOOGLE_CLIENT_SECRET + JWT_SECRET } from './envConfig' -import type { Request } from 'express' import { Strategy as GoogleStrategy } from 'passport-google-oauth20' import { findOrCreateUser } from '../services/auth.service' -import { type User } from '../types' +import { type CreateProfile, type User } from '../types' passport.use( new GoogleStrategy( @@ -31,7 +31,14 @@ passport.use( done: (err: Error | null, user?: Profile) => void ) { try { - const user = await findOrCreateUser(profile) + const createProfile: CreateProfile = { + id: profile.id, + primary_email: profile.emails?.[0]?.value ?? '', + first_name: profile.name?.givenName ?? '', + last_name: profile.name?.familyName ?? '', + image_url: profile.photos?.[0]?.value ?? '' + } + const user = await findOrCreateUser(createProfile) done(null, user) } catch (err) { done(err as Error) diff --git a/src/configs/linkedin-passport.ts b/src/configs/linkedin-passport.ts new file mode 100644 index 00000000..416276d4 --- /dev/null +++ b/src/configs/linkedin-passport.ts @@ -0,0 +1,101 @@ +import type { Request } from 'express' +import passport from 'passport' +import { Strategy as JwtStrategy } from 'passport-jwt' +import Profile from '../entities/profile.entity' +import { dataSource } from './dbConfig' +import { + JWT_SECRET, + LINKEDIN_CLIENT_ID, + LINKEDIN_CLIENT_SECRET, + LINKEDIN_REDIRECT_URL +} from './envConfig' + +import { Strategy as LinkedInStrategy } from 'passport-linkedin-oauth2' +import { findOrCreateUser } from '../services/auth.service' +import { type CreateProfile, type LinkedInProfile, type User } from '../types' + +passport.use( + new LinkedInStrategy( + { + clientID: LINKEDIN_CLIENT_ID, + clientSecret: LINKEDIN_CLIENT_SECRET, + callbackURL: LINKEDIN_REDIRECT_URL, + scope: ['openid', 'email', 'profile'], + passReqToCallback: true + }, + async function ( + req: Request, + accessToken: string, + refreshToken: string, + profile: passport.Profile, + done: (err: Error | null, user?: Profile) => void + ) { + try { + const data = profile as unknown as LinkedInProfile + const createProfile: CreateProfile = { + id: data.id, + primary_email: data?.email ?? '', + first_name: data?.givenName ?? '', + last_name: data?.familyName ?? '', + image_url: data?.picture ?? '' + } + const user = await findOrCreateUser(createProfile) + done(null, user) + } catch (err) { + done(err as Error) + } + } + ) +) + +passport.serializeUser((user: Express.User, done) => { + done(null, (user as User).primary_email) +}) + +passport.deserializeUser(async (primary_email: string, done) => { + try { + const profileRepository = dataSource.getRepository(Profile) + const user = await profileRepository.findOne({ + where: { primary_email }, + relations: ['mentor', 'mentee'] + }) + done(null, user) + } catch (err) { + done(err) + } +}) + +const cookieExtractor = (req: Request): string => { + let token = null + if (req?.cookies) { + token = req.cookies.jwt + } + return token +} + +const options = { + jwtFromRequest: cookieExtractor, + secretOrKey: JWT_SECRET +} + +passport.use( + new JwtStrategy(options, async (jwtPayload, done) => { + try { + const profileRepository = dataSource.getRepository(Profile) + const profile = await profileRepository.findOne({ + where: { uuid: jwtPayload.userId }, + relations: ['mentor', 'mentee'] + }) + + if (!profile) { + done(null, false) + } else { + done(null, profile) + } + } catch (error) { + done(error, false) + } + }) +) + +export default passport diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index d47e1995..26169e12 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -36,6 +36,30 @@ export const googleRedirect = async ( )(req, res, next) } +export const linkedinRedirect = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + passport.authenticate( + 'linkedin', + { failureRedirect: '/login' }, + (err: Error, user: Profile) => { + if (err) { + next(err) + return + } + if (!user) { + res.redirect('/login') + return + } + signAndSetCookie(res, user.uuid) + + res.redirect(process.env.CLIENT_URL ?? '/') + } + )(req, res, next) +} + export const register = async ( req: Request, res: Response diff --git a/src/routes/auth/auth.route.ts b/src/routes/auth/auth.route.ts index 6cab0e3d..e3c6c712 100644 --- a/src/routes/auth/auth.route.ts +++ b/src/routes/auth/auth.route.ts @@ -1,13 +1,14 @@ import express from 'express' +import passport from 'passport' import { - register, + googleRedirect, + linkedinRedirect, login, logout, - googleRedirect, + passwordReset, passwordResetRequest, - passwordReset + register } from '../../controllers/auth.controller' -import passport from 'passport' const authRouter = express.Router() @@ -22,7 +23,15 @@ authRouter.get( }) ) +authRouter.get( + '/linkedin', + passport.authenticate('linkedin', { + scope: ['openid', 'email', 'profile'] + }) +) + authRouter.get('/google/callback', googleRedirect) +authRouter.get('/linkedin/callback', linkedinRedirect) authRouter.post('/password-reset-request', passwordResetRequest) authRouter.put('/passwordreset', passwordReset) export default authRouter diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 8f2e2bc3..b478e984 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,15 +1,14 @@ -import { dataSource } from '../configs/dbConfig' import bcrypt from 'bcrypt' -import Profile from '../entities/profile.entity' -import type passport from 'passport' import jwt from 'jsonwebtoken' +import { dataSource } from '../configs/dbConfig' import { JWT_SECRET } from '../configs/envConfig' +import Profile from '../entities/profile.entity' +import { type CreateProfile, type ApiResponse } from '../types' import { - getPasswordResetEmailContent, - getPasswordChangedEmailContent + getPasswordChangedEmailContent, + getPasswordResetEmailContent } from '../utils' import { sendResetPasswordEmail } from './admin/email.service' -import { type ApiResponse } from '../types' export const registerUser = async ( email: string, @@ -88,21 +87,22 @@ export const loginUser = async ( } export const findOrCreateUser = async ( - profile: passport.Profile + createProfileDto: CreateProfile ): Promise => { const profileRepository = dataSource.getRepository(Profile) + let user = await profileRepository.findOne({ - where: { primary_email: profile.emails?.[0]?.value ?? '' }, - relations: ['mentor', 'mentee'] + where: { primary_email: createProfileDto.primary_email } }) + if (!user) { - const hashedPassword = await bcrypt.hash(profile.id, 10) // Use Google ID as password + const hashedPassword = await bcrypt.hash(createProfileDto.id, 10) user = profileRepository.create({ - primary_email: profile.emails?.[0]?.value ?? '', + primary_email: createProfileDto.primary_email, password: hashedPassword, - first_name: profile.name?.givenName, - last_name: profile.name?.familyName, - image_url: profile.photos?.[0]?.value ?? '' + first_name: createProfileDto.first_name, + last_name: createProfileDto.last_name, + image_url: createProfileDto.image_url }) await profileRepository.save(user) } diff --git a/src/types.ts b/src/types.ts index 4a6353e7..6773ba40 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,3 +26,19 @@ export interface ApiResponse { export interface User extends Express.User { primary_email: string } + +export interface CreateProfile { + primary_email: string + id: string + first_name: string + last_name: string + image_url: string +} + +export interface LinkedInProfile { + id: string + givenName: string + familyName: string + picture: string + email: string +}