Skip to content

Commit

Permalink
Merge pull request #1366 from sharetribe/add-facebook-login
Browse files Browse the repository at this point in the history
Add Facebook login
  • Loading branch information
OtterleyW authored Oct 13, 2020
2 parents a5bb186 + 8cd84e0 commit f238d60
Show file tree
Hide file tree
Showing 24 changed files with 744 additions and 25 deletions.
5 changes: 5 additions & 0 deletions .env-template
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ SHARETRIBE_SDK_CLIENT_SECRET=
REACT_APP_SHARETRIBE_MARKETPLACE_CURRENCY=USD
REACT_APP_CANONICAL_ROOT_URL=http://localhost:3000

# Social logins && SSO
# If the app or client id is not set the auhtentication option is not shown in FTW
REACT_APP_FACEBOOK_APP_ID=
FACEBOOK_APP_SECRET=

# This is overwritten by configuration in .env.development and
# .env.test. In production deployments use env variable and set it to
# 'production'
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
"moment": "^2.22.2",
"object.entries": "^1.1.2",
"object.values": "^1.1.1",
"passport": "^0.4.1",
"passport-facebook": "^3.0.0",
"path-to-regexp": "^6.1.0",
"prop-types": "^15.7.2",
"query-string": "^6.13.1",
Expand Down
78 changes: 78 additions & 0 deletions server/api/auth/facebook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const passport = require('passport');
const passportFacebook = require('passport-facebook');
const loginWithIdp = require('./loginWithIdp');

const radix = 10;
const PORT = parseInt(process.env.REACT_APP_DEV_API_SERVER_PORT, radix);
const rootUrl = process.env.REACT_APP_CANONICAL_ROOT_URL;
const clientID = process.env.REACT_APP_FACEBOOK_APP_ID;
const clientSecret = process.env.FACEBOOK_APP_SECRET;

const FacebookStrategy = passportFacebook.Strategy;
let callbackURL = null;

const useDevApiServer = process.env.NODE_ENV === 'development' && !!PORT;

if (useDevApiServer) {
callbackURL = `http://localhost:${PORT}/api/auth/facebook/callback`;
} else {
callbackURL = `${rootUrl}/api/auth/facebook/callback`;
}

const strategyOptions = {
clientID,
clientSecret,
callbackURL,
profileFields: ['id', 'name', 'emails'],
passReqToCallback: true,
};

const verifyCallback = (req, accessToken, refreshToken, profile, done) => {
const { email, first_name, last_name } = profile._json;
const state = req.query.state;
const queryParams = JSON.parse(state);

const { from, defaultReturn, defaultConfirm } = queryParams;

const userData = {
email,
firstName: first_name,
lastName: last_name,
accessToken,
refreshToken,
from,
defaultReturn,
defaultConfirm,
};

done(null, userData);
};

// ClientId is required when adding a new Facebook strategy to passport
if (clientID) {
passport.use(new FacebookStrategy(strategyOptions, verifyCallback));
}

exports.authenticateFacebook = (req, res, next) => {
const from = req.query.from ? req.query.from : null;
const defaultReturn = req.query.defaultReturn ? req.query.defaultReturn : null;
const defaultConfirm = req.query.defaultConfirm ? req.query.defaultConfirm : null;

const params = {
...(!!from && { from }),
...(!!defaultReturn && { defaultReturn }),
...(!!defaultConfirm && { defaultConfirm }),
};

const paramsAsString = JSON.stringify(params);

passport.authenticate('facebook', { scope: ['email'], state: paramsAsString })(req, res, next);
};

// Use custom callback for calling loginWithIdp enpoint
// to log in the user to Flex with the data from Facebook
exports.authenticateFacebookCallback = (req, res, next) => {
passport.authenticate('facebook', function(err, user) {
loginWithIdp(err, user, req, res, clientID, 'facebook');
})(req, res, next);
};
18 changes: 10 additions & 8 deletions server/api/auth/loginWithIdp.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ module.exports = (err, user, req, res, clientID, idpId) => {

// Save error details to cookie so that we can show
// relevant information in the frontend
res
return res
.cookie(
'st-autherror',
{
Expand All @@ -45,7 +45,7 @@ module.exports = (err, user, req, res, clientID, idpId) => {

// Save error details to cookie so that we can show
// relevant information in the frontend
res
return res
.cookie(
'st-autherror',
{
Expand All @@ -60,6 +60,8 @@ module.exports = (err, user, req, res, clientID, idpId) => {
.redirect(`${rootUrl}/login#`);
}

const { from, defaultReturn, defaultConfirm } = user;

const tokenStore = sharetribeSdk.tokenStore.expressCookieStore({
clientId: CLIENT_ID,
req,
Expand All @@ -78,7 +80,7 @@ module.exports = (err, user, req, res, clientID, idpId) => {
...baseUrl,
});

sdk
return sdk
.loginWithIdp({
idpId: 'facebook',
idpClientId: clientID,
Expand All @@ -90,10 +92,10 @@ module.exports = (err, user, req, res, clientID, idpId) => {
// We need to add # to the end of the URL because otherwise Facebook
// login will add their defaul #_#_ which breaks the routing in frontend.

if (user.returnUrl) {
res.redirect(`${rootUrl}${user.returnUrl}#`);
if (from) {
res.redirect(`${rootUrl}${from}#`);
} else {
res.redirect(`${rootUrl}/#`);
res.redirect(`${rootUrl}${defaultReturn}#`);
}
}
})
Expand All @@ -112,13 +114,13 @@ module.exports = (err, user, req, res, clientID, idpId) => {
lastName: user.lastName,
idpToken: `${user.accessToken}`,
idpId,
from: user.returnUrl,
from,
},
{
maxAge: 15 * 60 * 1000, // 15 minutes
}
);

res.redirect(`${rootUrl}/confirm#`);
res.redirect(`${rootUrl}${defaultConfirm}#`);
});
};
15 changes: 15 additions & 0 deletions server/apiRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const transitionPrivileged = require('./api/transition-privileged');

const createUserWithIdp = require('./api/auth/createUserWithIdp');

const { authenticateFacebook, authenticateFacebookCallback } = require('./api/auth/facebook');

const router = express.Router();

// ================ API router middleware: ================ //
Expand Down Expand Up @@ -52,6 +54,19 @@ router.post('/transaction-line-items', transactionLineItems);
router.post('/initiate-privileged', initiatePrivileged);
router.post('/transition-privileged', transitionPrivileged);

// Create user with identity provider (e.g. Facebook or Google)
// This endpoint is called to create a new user after user has confirmed
// they want to continue with the data fetched from IdP (e.g. name and email)
router.post('/auth/create-user-with-idp', createUserWithIdp);

// Facebook authentication endpoints

// This endpoint is called when user wants to initiate authenticaiton with Facebook
router.get('/auth/facebook', authenticateFacebook);

// This is the route for callback URL the user is redirected after authenticating
// with Facebook. In this route a Passport.js custom callback is used for calling
// loginWithIdp endpoint in Flex API to authenticate user to Flex
router.get('/auth/facebook/callback', authenticateFacebookCallback);

module.exports = router;
7 changes: 7 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const enforceSsl = require('express-enforces-ssl');
const path = require('path');
const sharetribeSdk = require('sharetribe-flex-sdk');
const sitemap = require('express-sitemap');
const passport = require('passport');
const auth = require('./auth');
const apiRouter = require('./apiRouter');
const renderer = require('./renderer');
Expand Down Expand Up @@ -145,6 +146,12 @@ if (!dev) {
}
}

// Initialize Passport.js (http://www.passportjs.org/)
// Passport is authentication middleware for Node.js
// We use passport to enable authenticating with
// a 3rd party identity provider (e.g. Facebook or Google)
app.use(passport.initialize());

// Server-side routes that do not render the application
app.use('/api', apiRouter);

Expand Down
33 changes: 33 additions & 0 deletions src/components/Button/Button.css
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,36 @@
.secondaryButton .checkmark {
stroke: var(--matterColorAnti);
}

/* Social logins && SSO buttons */

.socialButtonRoot {
@apply --marketplaceButtonStyles;
min-height: 48px;
background-color: var(--matterColorLight);

color: var(--matterColorDark);
font-weight: var(--fontWeightMedium);
font-size: 14px;

border: 1px solid #d2d2d2;
border-radius: 4px;

/* We need to add this to position the icon inside button */
position: relative;

@media (--viewportMedium) {
padding: 0;
}

&:hover,
&:focus {
background-color: var(--matterColorLight);
}
&:disabled {
background-color: var(--matterColorNegative);
}
}

.socialButton {
}
7 changes: 7 additions & 0 deletions src/components/Button/Button.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,10 @@ export const InlineTextButton = props => {
return <Button {...props} rootClassName={classes} />;
};
InlineTextButton.displayName = 'InlineTextButton';

export const SocialLoginButton = props => {
const classes = classNames(props.rootClassName || css.socialButtonRoot, css.socialButton);
return <Button {...props} rootClassName={classes} />;
};

SocialLoginButton.displayName = 'SocialLoginButton';
2 changes: 1 addition & 1 deletion src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export { default as ValidationError } from './ValidationError/ValidationError';
// First components that include only atomic components //
//////////////////////////////////////////////////////////

export { default as Button, PrimaryButton, SecondaryButton, InlineTextButton } from './Button/Button';
export { default as Button, PrimaryButton, SecondaryButton, InlineTextButton, SocialLoginButton } from './Button/Button';
export { default as CookieConsent } from './CookieConsent/CookieConsent';
export { default as ImageCarousel } from './ImageCarousel/ImageCarousel';
export { default as ImageFromFile } from './ImageFromFile/ImageFromFile';
Expand Down
5 changes: 4 additions & 1 deletion src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,14 @@ const siteInstagramPage = null;
// Facebook page is used in SEO schema (http://schema.org/Organization)
const siteFacebookPage = 'https://www.facebook.com/Sharetribe/';

// Social logins & SSO

// Note: Facebook app id is also used for tracking:
// Facebook counts shares with app or page associated by this id
// Currently it is unset, but you can read more about fb:app_id from
// https://developers.facebook.com/docs/sharing/webmasters#basic
// You should create one to track social sharing in Facebook
const facebookAppId = null;
const facebookAppId = process.env.REACT_APP_FACEBOOK_APP_ID;

const maps = {
mapboxAccessToken: process.env.REACT_APP_MAPBOX_ACCESS_TOKEN,
Expand Down
60 changes: 60 additions & 0 deletions src/containers/AuthenticationPage/AuthenticationPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,63 @@
.error {
@apply --marketplaceModalErrorStyles;
}

/* ================ Social logins & SSO ================ */

.signupWithIdpTitle {
@apply --marketplaceModalTitleStyles;
margin-top: 0;
margin-bottom: 0;
padding-top: 16px;
color: var(--matterColorDark);

@media (--viewportMedium) {
margin-top: 6px;
}
}

.confirmInfoText {
@apply --marketplaceH4FontStyles;
}

.buttonIcon {
position: absolute;
left: 0;
margin-left: 20px;
}

.socialButtonsOr {
width: 100%;
height: 32px;
margin: 28px 0 20px 0;
text-align: center;
position: relative;
background-color: var(--matterColorLight);

&:after {
content: '';
width: 100%;
border-bottom: solid 1px #d2d2d2;
position: absolute;
left: 0;
top: 50%;
z-index: 1;
}
}

@media (--viewportMedium) {
.socialButtonsOr {
height: 34px;
margin: 15px 0;
}
}

.socialButtonsOrText {
background-color: var(--matterColorLight);
width: auto;
display: inline-block;
z-index: 3;
padding: 0 20px 0 20px;
position: relative;
margin: 0;
}
Loading

0 comments on commit f238d60

Please sign in to comment.