Skip to content

Commit

Permalink
feat: Catch 401 errors and make refresh token
Browse files Browse the repository at this point in the history
  • Loading branch information
impactmass committed Oct 16, 2018
1 parent ffe5328 commit b88c947
Show file tree
Hide file tree
Showing 7 changed files with 183 additions and 21 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ OAUTH2_CLIENT_ID=reaction-next-starterkit
OAUTH2_CLIENT_SECRET=CHANGEME
OAUTH2_REDIRECT_URL=http://localhost:4000/callback
OAUTH2_IDP_HOST_URL=http://reaction.api.reaction.localhost:3000/
OAUTH2_IDP_REFRESH_TOKEN_URL=http://reaction.api.reaction.localhost:3000/token/refresh
OAUTH2_HOST=hydra.auth.reaction.localhost
OAUTH2_ADMIN_PORT=4445
CANONICAL_URL=http://example.com
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@
"apollo-client": "^2.2.7",
"apollo-link": "^1.2.2",
"apollo-link-context": "^1.0.8",
"apollo-link-error": "^1.0.9",
"apollo-link-error": "^1.1.1",
"apollo-link-http": "^1.5.4",
"body-parser": "^1.18.2",
"buffer": "^5.2.1",
"chalk": "^2.3.2",
"classnames": "^2.2.5",
"cookie-parser": "^1.4.3",
Expand Down
64 changes: 57 additions & 7 deletions src/lib/apollo/initApollo.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { ApolloClient } from "apollo-client";
import { ApolloLink } from "apollo-link";
import { ApolloLink, Observable } from "apollo-link";
import { HttpLink } from "apollo-link-http";
import { setContext } from "apollo-link-context";
import { onError } from "apollo-link-error";
import Cookies from "js-cookie";
import { InMemoryCache } from "apollo-cache-inmemory";
import fetch from "isomorphic-fetch";
import getConfig from "next/config";
import { omitTypenameLink } from "./omitVariableTypenameLink";
import { getUserSessionData, getOAuthClientConfig, createNewSessionData } from "./sessionData";

// Config
let graphqlUrl;
Expand All @@ -30,7 +32,7 @@ if (!process.browser) {

const create = (initialState, options) => {
// error handling for Apollo Link
const errorLink = onError(({ graphQLErrors, networkError }) => {
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => {
// eslint-disable-next-line no-console
Expand All @@ -39,6 +41,47 @@ const create = (initialState, options) => {
}

if (networkError) {
const errorCode = networkError.response && networkError.response.status;

// if a 401 error occurred, attempt to refresh the token, then make the API request again
if (errorCode === 401) {
const { refreshToken } = getUserSessionData();
const { clientId, clientSecret, refreshTokenUrl } = getOAuthClientConfig();
if (process && process.browser) {
const { headers } = operation.getContext();

return new Observable(async (observer) => {
try {
const url = new URL(refreshTokenUrl);
const params = { refreshToken, clientId, clientSecret };
Object.keys(params).forEach((key) => url.searchParams.append(key, params[key]));
const res = await fetch(url);
const json = await res.json();

operation.setContext({
headers: { ...headers, Authorization: json.access_token }
});

// Write new token into cookie
const newSessionInfo = createNewSessionData();
Cookies.set("storefront-session", newSessionInfo);

const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
};
// retry the request after setting new token
forward(operation).subscribe(subscriber);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error from Refresh token API: ", error);
observer.error(error);
}
});
}
}

// eslint-disable-next-line no-console
console.error(`[Network error]: ${networkError}`);
}
Expand All @@ -51,12 +94,19 @@ const create = (initialState, options) => {

// Set auth context
// https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-context
const authLink = setContext((__, { headers }) => ({
headers: {
...headers,
...authorizationHeader
const authLink = setContext((__, { headers }) => {
if (process && process.browser) {
const sessionData = getUserSessionData();
authorizationHeader = { Authorization: sessionData.accessToken };
}
}));

return {
headers: {
...headers,
...authorizationHeader
}
};
});

const httpLink = new HttpLink({ uri: `${graphqlUrl}`, credentials: "same-origin" });

Expand Down
71 changes: 71 additions & 0 deletions src/lib/apollo/sessionData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Buffer } from "buffer";
import Cookies from "js-cookie";

/**
* @name getSessionData
* @method
* @private
* @return {Object} session data
*/
export function getUserSessionData() {
let sessionData = {};
try {
const data = Cookies.get("storefront-session");
const expanded = Buffer.from(data, "base64").toString("utf8");
sessionData = JSON.parse(expanded, (key, value) => {
if (key === "user" && typeof value === "string") return JSON.parse(value);
return value;
});
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error while reading session data: ${error}`);
}
if (typeof sessionData.passport !== "object") {
return {};
}
return sessionData.passport.user;
}

/**
* @name getOAuthClientConfig
* @method
* @private
* @return {Object} client config data
*/
export function getOAuthClientConfig() {
let clientConfig = {};
try {
const data = Cookies.get("oauth_client_config");
const expanded = Buffer.from(data, "base64").toString("utf8");
clientConfig = JSON.parse(expanded);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error while reading session data: ${error}`);
}
return clientConfig;
}

/**
* @name createNewSessionData
* @method
* @private
* @param {Object} json session data
* @return {String} encoded string containing new session info
*/
export function createNewSessionData(json) {
let newSessionInfo = "";
try {
newSessionInfo = Buffer.from(JSON.stringify({
passport: {
user: {
accessToken: json.access_token,
refreshToken: json.refresh_token
}
}
})).toString("base64");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error writing new session info", error);
}
return newSessionInfo;
}
9 changes: 8 additions & 1 deletion src/lib/apollo/withApolloClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ export default function withApolloClient(WrappedComponent) {
// Provide the `url` prop data in case a GraphQL query uses it
rootMobxStores.routingStore.updateRoute({ query, pathname });

const user = req && req.session && req.session.passport && req.session.passport.user && JSON.parse(req.session.passport.user);
const userData = req && req.session && req.session.passport && req.session.passport.user;
let user;
if (typeof userData === "object") {
user = userData;
} else {
user = userData && JSON.parse(userData);
}

const apollo = initApollo({ cookies: req && req.cookies }, { accessToken: user && user.accessToken });

ctx.ctx.apolloClient = apollo;
Expand Down
27 changes: 20 additions & 7 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const { useStaticRendering } = require("mobx-react");
const logger = require("lib/logger");
const passport = require("passport");
const OAuth2Strategy = require("passport-oauth2");
const refresh = require("passport-oauth2-refresh");
const { decodeOpaqueId } = require("lib/utils/decoding");
const { appPath, dev } = require("./config");
const router = require("./routes");
Expand All @@ -32,11 +31,9 @@ passport.use("oauth2", new OAuth2Strategy({
state: true,
scope: ["offline"]
}, (accessToken, refreshToken, profile, cb) => {
cb(null, { accessToken, profile });
cb(null, { accessToken, refreshToken });
}));

passport.use("refresh", refresh);

// The value passed to `done` here is stored on the session.
// We save the full user object in the session.
passport.serializeUser((user, done) => {
Expand All @@ -46,6 +43,7 @@ passport.serializeUser((user, done) => {
// The value returned from `serializeUser` is passed in from the session here,
// to get the user. We save the full user object in the session.
passport.deserializeUser((user, done) => {
if (typeof user === "object") return done(null, user);
done(null, JSON.parse(user));
});

Expand All @@ -54,17 +52,20 @@ app
.then(() => {
const server = express();

const { SESSION_SECRET, SESSION_MAX_AGE_MS } = process.env;
const { SESSION_MAX_AGE_MS } = process.env;
const maxAge = SESSION_MAX_AGE_MS ? Number(SESSION_MAX_AGE_MS) : 24 * 60 * 60 * 1000; // 24 hours

// We use a client-side cookie session instead of a server session so that there are no
// issues when load balancing without sticky sessions.
// https://www.npmjs.com/package/cookie-session
// The cookie is not `signed` because it's value is to be changed when access_token is
// refreshed in the browser.
server.use(cookieSession({
// https://www.npmjs.com/package/cookie-session#options
keys: [SESSION_SECRET],
maxAge,
name: "storefront-session"
name: "storefront-session",
httpOnly: false,
signed: false
}));

// http://www.passportjs.org/docs/configure/
Expand All @@ -85,6 +86,18 @@ app
// This endpoint handles OAuth2 requests (exchanges code for token)
server.get("/callback", passport.authenticate("oauth2"), (req, res) => {
// After success, redirect to the page we came from originally
// Before redirecting, we add client config info in cookie. They're needed when refreshing a token
const clientId = process.env.OAUTH2_CLIENT_ID;
const clientSecret = process.env.OAUTH2_CLIENT_SECRET;
const refreshTokenUrl = process.env.OAUTH2_IDP_REFRESH_TOKEN_URL;
let encoded = "";
try {
const str = JSON.stringify({ clientId, clientSecret, refreshTokenUrl });
encoded = Buffer.from(str).toString("base64");
} catch (error) {
logger.error("Error creating encoded config string", error);
}
res.cookie("oauth_client_config", encoded);
res.redirect(req.session.redirectTo || "/");
});

Expand Down
29 changes: 24 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1025,7 +1025,6 @@
"@reactioncommerce/components@0.45.2":
version "0.45.2"
resolved "https://registry.yarnpkg.com/@reactioncommerce/components/-/components-0.45.2.tgz#b9295eb2e549b0cf8c840f0b16899edb805d8411"
integrity sha512-QxVGnokhXTyzuQkg5CGhKqIr1l+4ECRUkGtYt9TYbIJ9jy4dQAR5Dfld+4ZWdLsEj63YylLW+LaNH4mON0be1A==
dependencies:
"@material-ui/core" "^3.1.0"
lodash.debounce "^4.0.8"
Expand Down Expand Up @@ -1410,11 +1409,11 @@ apollo-link-dedup@^1.0.0:
dependencies:
apollo-link "^1.2.2"

apollo-link-error@^1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/apollo-link-error/-/apollo-link-error-1.0.9.tgz#83bbe019a3bca7c602c399889b313a7e5e22713f"
apollo-link-error@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/apollo-link-error/-/apollo-link-error-1.1.1.tgz#69d7124d4dc11ce60f505c940f05d4f1aa0945fb"
dependencies:
apollo-link "^1.2.2"
apollo-link "^1.2.3"

apollo-link-http-common@^0.2.4:
version "0.2.4"
Expand All @@ -1437,6 +1436,13 @@ apollo-link@^1.0.0, apollo-link@^1.2.2:
apollo-utilities "^1.0.0"
zen-observable-ts "^0.8.9"

apollo-link@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.3.tgz#9bd8d5fe1d88d31dc91dae9ecc22474d451fb70d"
dependencies:
apollo-utilities "^1.0.0"
zen-observable-ts "^0.8.10"

apollo-utilities@^1.0.0, apollo-utilities@^1.0.12:
version "1.0.12"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.12.tgz#9e2b2a34cf89f3bf0d73a664effd8c1bb5d1b7f7"
Expand Down Expand Up @@ -2194,6 +2200,13 @@ buffer@^5.0.3:
base64-js "^1.0.2"
ieee754 "^1.1.4"

buffer@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.2.1.tgz#dd57fa0f109ac59c602479044dca7b8b3d0b71d6"
dependencies:
base64-js "^1.0.2"
ieee754 "^1.1.4"

builtin-modules@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
Expand Down Expand Up @@ -9244,6 +9257,12 @@ yargs@~3.10.0:
decamelize "^1.0.0"
window-size "0.1.0"

zen-observable-ts@^0.8.10:
version "0.8.10"
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.10.tgz#18e2ce1c89fe026e9621fd83cc05168228fce829"
dependencies:
zen-observable "^0.8.0"

zen-observable-ts@^0.8.9:
version "0.8.9"
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.9.tgz#d3c97af08c0afdca37ebcadf7cc3ee96bda9bab1"
Expand Down

0 comments on commit b88c947

Please sign in to comment.