Skip to content

Commit

Permalink
Merge pull request #1364 from sharetribe/social-logins
Browse files Browse the repository at this point in the history
Add Facebook login to FTW
  • Loading branch information
OtterleyW authored Oct 15, 2020
2 parents e1d50d4 + 4a9d00e commit c5843a2
Show file tree
Hide file tree
Showing 34 changed files with 1,193 additions and 24 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ way to update this template, but currently, we follow a pattern:

## Upcoming version 2020-XX-XX

## [v6.4.0] 2020-10-14

- [add] Add Facebook login as a first step towards supporting social logins and SSO in FTW. This PR
introduces new endpoints `createUserWithIdp` and `loginWithIdp` and strategy for logging in with
Facebook. See the PR for the more detailed view of the changes.
[#1364](https://github.com/sharetribe/ftw-daily/pull/1364)
- [fix] Fix missing proptype warnings in `TransactionPage` and `TransactionPanel` tests.
[#1363](https://github.com/sharetribe/ftw-daily/pull/1363)
- [fix] Improve error handling by passing error details forward instead of creating a new error that
Expand All @@ -22,6 +28,8 @@ way to update this template, but currently, we follow a pattern:
- [fix] Remove duplicate page schema from body.
[#1355](https://github.com/sharetribe/ftw-daily/pull/1355)

[v6.4.0]: https://github.com/sharetribe/ftw-daily/compare/v6.3.1...v6.4.0

## [v6.3.1] 2020-08-19

- [fix] Fix popup-button in SelectSingleFilterPopup.css and adjust Footer with correct baselines.
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "app",
"version": "6.3.1",
"version": "6.4.0",
"private": true,
"license": "Apache-2.0",
"dependencies": {
Expand Down 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 All @@ -52,7 +54,7 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"seedrandom": "^3.0.5",
"sharetribe-flex-sdk": "1.12.0",
"sharetribe-flex-sdk": "1.13.0",
"sharetribe-scripts": "3.1.1",
"smoothscroll-polyfill": "^0.4.0",
"source-map-support": "^0.5.9",
Expand Down
81 changes: 81 additions & 0 deletions server/api/auth/createUserWithIdp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const http = require('http');
const https = require('https');
const sharetribeSdk = require('sharetribe-flex-sdk');
const { handleError, serialize, typeHandlers } = require('../../api-util/sdk');

const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID;
const CLIENT_SECRET = process.env.SHARETRIBE_SDK_CLIENT_SECRET;
const TRANSIT_VERBOSE = process.env.REACT_APP_SHARETRIBE_SDK_TRANSIT_VERBOSE === 'true';
const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true';
const BASE_URL = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL;

const FACBOOK_APP_ID = process.env.REACT_APP_FACEBOOK_APP_ID;
const GOOGLE_CLIENT_ID = process.env.REACT_APP_GOOGLE_CLIENT_ID;

const FACEBOOK_IDP_ID = 'facebook';
const GOOGLE_IDP_ID = 'google';

// Instantiate HTTP(S) Agents with keepAlive set to true.
// This will reduce the request time for consecutive requests by
// reusing the existing TCP connection, thus eliminating the time used
// for setting up new TCP connections.
const httpAgent = new http.Agent({ keepAlive: true });
const httpsAgent = new https.Agent({ keepAlive: true });

const baseUrl = BASE_URL ? { baseUrl: BASE_URL } : {};

module.exports = (req, res) => {
const tokenStore = sharetribeSdk.tokenStore.expressCookieStore({
clientId: CLIENT_ID,
req,
res,
secure: USING_SSL,
});

const sdk = sharetribeSdk.createInstance({
transitVerbose: TRANSIT_VERBOSE,
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
httpAgent,
httpsAgent,
tokenStore,
typeHandlers,
...baseUrl,
});

const { idpToken, idpId, ...rest } = req.body;

// Choose the idpClientId based on which authentication method is used.
const idpClientId =
idpId === FACEBOOK_IDP_ID ? FACBOOK_APP_ID : idpId === GOOGLE_IDP_ID ? GOOGLE_CLIENT_ID : null;

sdk.currentUser
.createWithIdp({ idpId: FACEBOOK_IDP_ID, idpClientId, idpToken, ...rest })
.then(() =>
// After the user is created, we need to call loginWithIdp endpoint
// so that the user will be logged in.
sdk.loginWithIdp({
idpId,
idpClientId: `${idpClientId}`,
idpToken: `${idpToken}`,
})
)
.then(apiResponse => {
const { status, statusText, data } = apiResponse;
res
.clearCookie('st-authinfo')
.status(status)
.set('Content-Type', 'application/transit+json')
.send(
serialize({
status,
statusText,
data,
})
)
.end();
})
.catch(e => {
handleError(res, e);
});
};
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);
};
126 changes: 126 additions & 0 deletions server/api/auth/loginWithIdp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
const http = require('http');
const https = require('https');
const sharetribeSdk = require('sharetribe-flex-sdk');
const sdkUtils = require('../../api-util/sdk');

const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID;
const CLIENT_SECRET = process.env.SHARETRIBE_SDK_CLIENT_SECRET;
const TRANSIT_VERBOSE = process.env.REACT_APP_SHARETRIBE_SDK_TRANSIT_VERBOSE === 'true';
const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true';
const BASE_URL = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL;
const rootUrl = process.env.REACT_APP_CANONICAL_ROOT_URL;

// Instantiate HTTP(S) Agents with keepAlive set to true.
// This will reduce the request time for consecutive requests by
// reusing the existing TCP connection, thus eliminating the time used
// for setting up new TCP connections.
const httpAgent = new http.Agent({ keepAlive: true });
const httpsAgent = new https.Agent({ keepAlive: true });

const baseUrl = BASE_URL ? { baseUrl: BASE_URL } : {};

module.exports = (err, user, req, res, clientID, idpId) => {
if (err) {
console.error(err);

// Save error details to cookie so that we can show
// relevant information in the frontend
return res
.cookie(
'st-autherror',
{
status: err.status,
code: err.code,
message: err.message,
},
{
maxAge: 15 * 60 * 1000, // 15 minutes
}
)
.redirect(`${rootUrl}/login#`);
}

if (!user) {
console.error('Failed to fetch user details from identity provider!');

// Save error details to cookie so that we can show
// relevant information in the frontend
return res
.cookie(
'st-autherror',
{
status: 'Bad Request',
code: 400,
message: 'Failed to fetch user details from identity provider!',
},
{
maxAge: 15 * 60 * 1000, // 15 minutes
}
)
.redirect(`${rootUrl}/login#`);
}

const { from, defaultReturn, defaultConfirm } = user;

const tokenStore = sharetribeSdk.tokenStore.expressCookieStore({
clientId: CLIENT_ID,
req,
res,
secure: USING_SSL,
});

const sdk = sharetribeSdk.createInstance({
transitVerbose: TRANSIT_VERBOSE,
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
httpAgent,
httpsAgent,
tokenStore,
typeHandlers: sdkUtils.typeHandlers,
...baseUrl,
});

return sdk
.loginWithIdp({
idpId: 'facebook',
idpClientId: clientID,
idpToken: user ? user.accessToken : null,
})
.then(response => {
if (response.status === 200) {
// If the user was authenticated, redirect back to to LandingPage
// 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 (from) {
res.redirect(`${rootUrl}${from}#`);
} else {
res.redirect(`${rootUrl}${defaultReturn}#`);
}
}
})
.catch(() => {
// If authentication fails, we want to create a new user with idp
// For this we will need to pass some information to frontend so
// that we can use that information in createUserWithIdp api call.
// The createUserWithIdp api call is triggered from frontend
// after showing a confirm page to user

res.cookie(
'st-authinfo',
{
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
idpToken: `${user.accessToken}`,
idpId,
from,
},
{
maxAge: 15 * 60 * 1000, // 15 minutes
}
);

res.redirect(`${rootUrl}${defaultConfirm}#`);
});
};
19 changes: 19 additions & 0 deletions server/apiRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const transactionLineItems = require('./api/transaction-line-items');
const initiatePrivileged = require('./api/initiate-privileged');
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 @@ -50,4 +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
Loading

0 comments on commit c5843a2

Please sign in to comment.