Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Login as user #1254

Merged
merged 5 commits into from
Feb 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ way to update this template, but currently, we follow a pattern:

## Upcoming version 2020-XX-XX

- [add] Support for logging in as a user from Console. [#1254](https://github.com/sharetribe/ftw-daily/pull/1254)
- [change] Add `handlebars` 4.5.3 and `serialize-javascript` 2.1.1 to resolutions in `package.json`.
[#1251](https://github.com/sharetribe/ftw-daily/pull/1251)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"redux": "^4.0.1",
"redux-thunk": "^2.3.0",
"seedrandom": "^3.0.3",
"sharetribe-flex-sdk": "^1.8.0",
"sharetribe-flex-sdk": "^1.9.0",
"sharetribe-scripts": "3.1.1",
"smoothscroll-polyfill": "^0.4.0",
"source-map-support": "^0.5.9",
Expand Down
156 changes: 156 additions & 0 deletions server/apiRouter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* This file contains server side endpoints that can be used to perform backend
* tasks that can not be handled in the browser.
*
* The endpoints should not clash with the application routes. Therefore, the
* enpoints are prefixed in the main server where this file is used.
*/

const http = require('http');
const https = require('https');
const express = require('express');
const crypto = require('crypto');
const sharetribeSdk = require('sharetribe-flex-sdk');
const Decimal = require('decimal.js');

const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID;
const ROOT_URL = process.env.REACT_APP_CANONICAL_ROOT_URL;
const CONSOLE_URL =
process.env.SERVER_SHARETRIBE_CONSOLE_URL || 'https://flex-console.sharetribe.com';
const BASE_URL = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL;
const TRANSIT_VERBOSE = process.env.REACT_APP_SHARETRIBE_SDK_TRANSIT_VERBOSE === 'true';
const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true';

const router = express.Router();

// redirect_uri param used when initiating a login as authenitcation flow and
// when requesting a token useing an authorization code
const loginAsRedirectUri = `${ROOT_URL.replace(/\/$/, '')}/api/login-as`;

// 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 });

// Cookies used for authorization code authentication.
const stateKey = `st-${CLIENT_ID}-oauth2State`;
const codeVerifierKey = `st-${CLIENT_ID}-pkceCodeVerifier`;

/**
* Makes a base64 string URL friendly by
* replacing unaccepted characters.
*/
const urlifyBase64 = base64Str =>
base64Str
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');

// Initiates an authorization code authentication flow. This authentication flow
// enables marketplace operators that have an ongoing Console session to log
// into their marketplace as a user of the marketpalce.
//
// The authroization code is requested from Console and it is used to request a
// token from the Flex Auth API.
//
// This endpoint will return a 302 to Console which requests the authorization
// code. Console returns a 302 with the code to the `redirect_uri` that is
// passed in this reponse. The request to the redirect URI is handled with the
// `/login-as` endpoint.
router.get('/initiate-login-as', (req, res) => {
const userId = req.query.user_id;

if (!userId) {
return res.status(400).send('Missing query parameter: user_id.');
}
if (!ROOT_URL) {
return res.status(409).send('Marketplace canonical root URL is missing.');
}

const state = urlifyBase64(crypto.randomBytes(32).toString('base64'));
const codeVerifier = urlifyBase64(crypto.randomBytes(32).toString('base64'));
const hash = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64');
const codeChallenge = urlifyBase64(hash);
const authorizeServerUrl = `${CONSOLE_URL}/api/authorize-as`;

const location = `${authorizeServerUrl}?\
response_type=code&\
client_id=${CLIENT_ID}&\
redirect_uri=${loginAsRedirectUri}&\
user_id=${userId}&\
state=${state}&\
code_challenge=${codeChallenge}&\
code_challenge_method=S256`;

const cookieOpts = {
maxAge: 1000 * 30, // 30 seconds
secure: USING_SSL,
};

res.cookie(stateKey, state, cookieOpts);
res.cookie(codeVerifierKey, codeVerifier, cookieOpts);
return res.redirect(location);
});

// Works as the redirect_uri passed in an authorization code request. Receives
// an authorization code and uses that to log in and redirect to the landing
// page.
router.get('/login-as', (req, res) => {
const { code, state, error } = req.query;
const storedState = req.cookies[stateKey];

if (state !== storedState) {
return res.status(401).send('Invalid state parameter.');
}

if (error) {
return res.status(401).send(`Failed to authorize as a user, error: ${error}.`);
}

const codeVerifier = req.cookies[codeVerifierKey];

// clear state and code verifier cookies
res.clearCookie(stateKey, { secure: USING_SSL });
res.clearCookie(codeVerifierKey, { secure: USING_SSL });

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

const sdk = sharetribeSdk.createInstance({
transitVerbose: TRANSIT_VERBOSE,
clientId: CLIENT_ID,
httpAgent: httpAgent,
httpsAgent: httpsAgent,
tokenStore,
typeHandlers: [
{
type: sharetribeSdk.types.BigDecimal,
customType: Decimal,
writer: v => new sharetribeSdk.types.BigDecimal(v.toString()),
reader: v => new Decimal(v.value),
},
],
...baseUrl,
});

sdk
.login({
code,
redirect_uri: loginAsRedirectUri,
code_verifier: codeVerifier,
})
.then(() => res.redirect('/'))
.catch(() => res.status(401).send('Unable to authenticate as a user'));
});

module.exports = router;
17 changes: 11 additions & 6 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const sharetribeSdk = require('sharetribe-flex-sdk');
const Decimal = require('decimal.js');
const sitemap = require('express-sitemap');
const auth = require('./auth');
const apiRouter = require('./apiRouter');
const renderer = require('./renderer');
const dataLoader = require('./dataLoader');
const fs = require('fs');
Expand Down Expand Up @@ -132,6 +133,9 @@ if (!dev) {
}
}

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

const noCacheHeaders = {
'Cache-control': 'no-cache, no-store, must-revalidate',
};
Expand Down Expand Up @@ -210,14 +214,15 @@ app.get('*', (req, res) => {
// authentication.

const token = tokenStore.getToken();
const refreshTokenExists = !!token && !!token.refresh_token;

if (refreshTokenExists) {
// If refresh token exists, we assume that client can handle the situation
const scopes = !!token && token.scopes;
const isAnonymous = !!scopes && scopes.length === 1 && scopes[0] === 'public-read';
if (isAnonymous) {
res.status(401).send(html);
} else {
// If the token is associated with other than public-read scopes, we
// assume that client can handle the situation
// TODO: improve by checking if the token is valid (needs an API call)
res.status(200).send(html);
} else {
res.status(401).send(html);
}
} else if (context.forbidden) {
res.status(403).send(html);
Expand Down
2 changes: 1 addition & 1 deletion src/ducks/Auth.duck.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { clearCurrentUser, fetchCurrentUser } from './user.duck';
import { storableError } from '../util/errors';
import * as log from '../util/log';

const authenticated = authInfo => authInfo && authInfo.grantType === 'refresh_token';
const authenticated = authInfo => authInfo && authInfo.isAnonymous === false;

// ================ Action types ================ //

Expand Down
6 changes: 3 additions & 3 deletions src/ducks/Auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,16 @@ describe('Auth duck', () => {
});

it('should set initial state for anonymous users', () => {
const authInfoAnonymous = { grantType: 'client_credentials' };
const authInfoAnonymous = { isAnonymous: true };
const initialState = reducer();
expect(initialState.authInfoLoaded).toEqual(false);
const state = reducer(initialState, authInfoSuccess(authInfoAnonymous));
expect(state.authInfoLoaded).toEqual(true);
expect(state.isAuthenticated).toEqual(false);
});

it('should set initial state for unauthenticated users', () => {
const authInfoLoggedIn = { grantType: 'refresh_token' };
it('should set initial state for authenticated users', () => {
const authInfoLoggedIn = { isAnonymous: false };
const initialState = reducer();
expect(initialState.authInfoLoaded).toEqual(false);
const state = reducer(initialState, authInfoSuccess(authInfoLoggedIn));
Expand Down
6 changes: 6 additions & 0 deletions src/routeConfiguration.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const draftSlug = 'draft';

const RedirectToLandingPage = () => <NamedRedirect name="LandingPage" />;

// NOTE: Most server-side endpoints are prefixed with /api. Requests to those
// endpoints are indended to be handled in the server instead of the browser and
// they will not render the application. So remember to avoid routes starting
// with /api and if you encounter clashing routes see server/index.js if there's
// a conflicting route defined there.

// Our routes are exact by default.
// See behaviour from Routes.js where Route is created.
const routeConfiguration = () => {
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10132,10 +10132,10 @@ [email protected]:
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==

sharetribe-flex-sdk@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.8.0.tgz#d7f24bf416f15c5f20d7b6830e0d6e5832082b43"
integrity sha512-600NLlvQamxQP8hKhyWlytl5cycoYxAMce1xXAFxTMAoNiOyLEh9K/6I4nrf9AowpDL+2IoPvWGZd9ftl4xeGQ==
sharetribe-flex-sdk@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.9.0.tgz#c7af04022acb77e75e1fac2bab8a0c11154aab24"
integrity sha512-cgoswKAOZ0Zi2uFXLoo+/dpkSshELWqmLjEKaBxtJ53CzuHvaLj6gF0q+QhGFfp/BnQjMBjMyp/Box9InIaBKg==
dependencies:
axios "^0.19.0"
js-cookie "^2.1.3"
Expand Down