diff --git a/.env-template b/.env-template index 469225c32..a48fa19db 100644 --- a/.env-template +++ b/.env-template @@ -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' diff --git a/CHANGELOG.md b/CHANGELOG.md index cf7148ab9..b9952c5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ https://github.com/sharetribe/flex-template-web/ ## Upcoming version 2020-XX-XX +## [v8.4.0] 2020-10-15 + +### Updates from upstream (FTW-daily v6.4.0) + +- [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 +- [fix] Fix missing proptype warnings in TransactionPage and TransactionPanel tests. #1363 +- [fix] Improve error handling by passing error details forward instead of creating a new error that + hides the details when making API call to FTW server. #1361 +- [fix] Remove duplicate page schema from body. #1355 + +[v8.4.0]: https://github.com/sharetribe/ftw-hourly/compare/v8.3.1...v8.4.0 + ## [v8.3.1] 2020-08-19 - [fix] Fix popup-button in SelectSingleFilterPopup.css and adjust Footer with correct baselines. diff --git a/package.json b/package.json index 23051004f..e8c6f787e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app", - "version": "8.3.1", + "version": "8.4.0", "private": true, "license": "Apache-2.0", "dependencies": { @@ -36,6 +36,8 @@ "moment-timezone": "^0.5.26", "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", @@ -54,7 +56,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", diff --git a/server/api-util/sdk.js b/server/api-util/sdk.js index f2d6ff185..757c2f20f 100644 --- a/server/api-util/sdk.js +++ b/server/api-util/sdk.js @@ -55,13 +55,16 @@ exports.deserialize = str => { exports.handleError = (res, error) => { console.error(error); if (error.status && error.statusText && error.data) { + const { status, statusText, data } = error; + // JS SDK error res .status(error.status) .json({ - status: error.status, - statusText: error.statusText, - data: error.data, + name: 'Local API request failed', + status, + statusText, + data, }) .end(); } else { diff --git a/server/api/auth/createUserWithIdp.js b/server/api/auth/createUserWithIdp.js new file mode 100644 index 000000000..6270d674e --- /dev/null +++ b/server/api/auth/createUserWithIdp.js @@ -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); + }); +}; diff --git a/server/api/auth/facebook.js b/server/api/auth/facebook.js new file mode 100644 index 000000000..a5380b46f --- /dev/null +++ b/server/api/auth/facebook.js @@ -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); +}; diff --git a/server/api/auth/loginWithIdp.js b/server/api/auth/loginWithIdp.js new file mode 100644 index 000000000..75a75f89d --- /dev/null +++ b/server/api/auth/loginWithIdp.js @@ -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}#`); + }); +}; diff --git a/server/apiRouter.js b/server/apiRouter.js index 1a09735cb..4d21d277f 100644 --- a/server/apiRouter.js +++ b/server/apiRouter.js @@ -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: ================ // @@ -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; diff --git a/server/index.js b/server/index.js index fe00a801e..1450068df 100644 --- a/server/index.js +++ b/server/index.js @@ -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'); @@ -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); diff --git a/src/components/Button/Button.css b/src/components/Button/Button.css index 1cb34644d..fe78cd381 100644 --- a/src/components/Button/Button.css +++ b/src/components/Button/Button.css @@ -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 { +} diff --git a/src/components/Button/Button.js b/src/components/Button/Button.js index f934c25af..b0de63bce 100644 --- a/src/components/Button/Button.js +++ b/src/components/Button/Button.js @@ -100,3 +100,10 @@ export const InlineTextButton = props => { return