chore: migrate to composition API and auth0-spa

This commit is contained in:
2020-01-21 15:51:51 +01:00
parent 565a3aa66e
commit e6c87e2f46
44 changed files with 2489 additions and 1585 deletions
-124
View File
@@ -1,124 +0,0 @@
import auth0 from 'auth0-js';
import {
storeStateAndNonce,
clearStateAndNonce,
getStateAndNonce,
storeAuth,
getIdToken,
getUserInfo,
clear,
getExpiresAt,
getAccessToken,
} from './storage';
export default class AuthenticationClient {
/**
* Instantiates an authentication client.
* You should only need one per app and authentication method.
* @param {Object} auth0Config An auth0 configuration object, as per their API.
*/
constructor(auth0Config) {
this.config = auth0Config;
this.webAuth = new auth0.WebAuth(auth0Config);
}
logout() {
clear();
}
getUserInfo() {
return getUserInfo();
}
isAuthenticated() {
return !!getIdToken();
}
isExpired() {
const expiresAt = getExpiresAt();
return new Date().getTime() > expiresAt && this.isAuthenticated();
}
/**
* Triggers a login by redirecting to auth0.
*/
triggerLogin(state) {
// clear the state and nonce when triggering a login - otherwise hash parsing wont work.
clearStateAndNonce();
this.webAuth.authorize({ state: JSON.stringify(state) });
}
triggerSilentLogin(state) {
if (!state) {
throw new Error('You must specify a state.');
}
const nonce = new Date().getTime().toString();
// before we trigger the silent login - store the state and nonce in localstorage.
storeStateAndNonce(state, nonce);
let url = this.webAuth.client.buildAuthorizeUrl({
...this.config,
nonce,
state: JSON.stringify(state),
});
url += '&prompt=none';
window.location.href = url;
}
checkSession(resolve, reject) {
const state = {returnUrl: window.location.href};
const nonce = new Date().getTime().toString();
storeStateAndNonce(state, nonce);
this.webAuth.checkSession({state: JSON.stringify(state), nonce: nonce}, (err, result) => {
if (err) {
return reject(err || 'Re-authentication failed');
} else {
return this.storeSession(result, resolve, reject);
}
});
}
storeSession(authResult, resolve, reject) {
this.webAuth.client.userInfo(authResult.accessToken, (err, user) => {
if (err) {
// if any error happens when fetching user info - nuke all session info
this.logout();
return reject('Authentication failed');
}
const expiresAt = JSON.stringify(authResult.expiresIn * 1000 + new Date().getTime());
storeAuth({ ...authResult, user, expiresAt });
return resolve(JSON.parse(authResult.state));
});
}
handleAuthentication() {
return new Promise((resolve, reject) => {
// retrieve stored state and nonce from localstorage
const { state, nonce } = getStateAndNonce();
// however, if there is no state and nonce stored - do not provide any param to parseHash.
// Otherwise, the non-silent logins will fail due to invalid state.
const parseHashParam = state && nonce ? { state, nonce } : undefined;
this.webAuth.parseHash(parseHashParam, (err, authResult) => {
if (authResult && authResult.accessToken && authResult.idToken) {
// If we fail to either set the session or store user info - reject and clear the session.
try {
return this.storeSession(authResult, resolve, reject);
} catch (error) {
return reject(error || 'Authentication failed');
}
} else {
return reject(err || 'Authentication failed');
}
});
});
}
idToken() {
return getIdToken();
}
accessToken() {
return getAccessToken();
}
}
-152
View File
@@ -1,152 +0,0 @@
import AuthClient, { __RewireAPI__ as rewire } from '.'; // eslint-disable-line
import { __RewireAPI__ as storageRewire } from './storage'; // eslint-disable-line
describe('Auth module', () => {
const STORAGE = {
ACCESS: storageRewire.__get__('STORAGE_ACCESS'),
ID: storageRewire.__get__('STORAGE_ID'),
EXPIRES: storageRewire.__get__('STORAGE_EXPIRES'),
USER: storageRewire.__get__('STORAGE_USER'),
STATE: storageRewire.__get__('STORAGE_STATE'),
NONCE: storageRewire.__get__('STORAGE_NONCE'),
};
let authZero;
let TEST_DATE;
let webAuth;
// populates the storage with some mock data to mimic a login
const populateStorage = () => {
localStorage.setItem(STORAGE.ACCESS, 'foo');
localStorage.setItem(STORAGE.ID, 'foo');
localStorage.setItem(STORAGE.EXPIRES, 'foo');
localStorage.setItem(STORAGE.USER, '{"foo": "bar"}');
localStorage.setItem(STORAGE.STATE, 'foo');
localStorage.setItem(STORAGE.NONCE, 'foo');
};
beforeEach(() => {
// freeze time for this test suite - we're going back to the 90s!
TEST_DATE = new Date('1999');
global.Date = jest.fn(() => TEST_DATE);
// mop up current localStorage before each run - it will not clean itself
localStorage.clear();
authZero = {
authorize: jest.fn(),
parseHash: jest.fn(),
client: {
buildAuthorizeUrl: jest.fn(() => 'https://example.com'),
userInfo: jest.fn(),
},
};
rewire.__set__('auth0', { WebAuth: () => authZero });
webAuth = new AuthClient();
});
it('stores user info on successful login', (done) => {
const state = { returnUrl: '/foo' };
const authResult = {
accessToken: 'foo',
idToken: 'bar',
expiresIn: 86400,
state: JSON.stringify(state),
};
const userInfo = { foo: 'bar' };
authZero.parseHash = jest.fn((options, cb) => {
cb(null, authResult);
});
authZero.client.userInfo = jest.fn((token, cb) => {
cb(null, userInfo);
});
return webAuth.handleAuthentication()
.then((result) => {
const expiresAt = JSON.stringify(authResult.expiresIn * 1000 + TEST_DATE.getTime());
expect(authZero.parseHash).toBeCalled();
expect(localStorage.getItem(STORAGE.ACCESS)).toEqual(authResult.accessToken);
expect(localStorage.getItem(STORAGE.ID)).toEqual(authResult.idToken);
expect(localStorage.getItem(STORAGE.EXPIRES)).toEqual(expiresAt);
expect(localStorage.getItem(STORAGE.USER)).toEqual(JSON.stringify(userInfo));
// verify that the state we sent in is what we get back
expect(result).toEqual(state);
done();
});
});
it('clears state and nonce from storage on normal login attempt', () => {
populateStorage();
webAuth.triggerLogin({ returnUrl: '/' });
expect(localStorage.getItem(STORAGE.NONCE)).toBeFalsy();
expect(localStorage.getItem(STORAGE.STATE)).toBeFalsy();
});
it('sets correct localstorage content on silent login attempt', () => {
const state = { returnUrl: '/some-path/', foo: 'bar', ham: 34 };
const serializedState = JSON.stringify(state);
webAuth.triggerSilentLogin(state);
expect(localStorage.getItem(STORAGE.NONCE)).toEqual(TEST_DATE.getTime().toString());
expect(localStorage.getItem(STORAGE.STATE)).toEqual(serializedState);
});
it('rejects failed logins', (done) => {
const authResult = {
error: 'failed to authenticate',
};
authZero.parseHash = jest.fn((options, cb) => {
cb(null, authResult);
});
return webAuth.handleAuthentication()
.catch(() => {
expect(authZero.parseHash).toBeCalled();
expect(localStorage.length).toBe(0);
done();
});
});
it('rejects logins followed by failed user fetches', (done) => {
const authResult = {
accessToken: 'foo',
idToken: 'bar',
expiresIn: 86400,
};
authZero.parseHash = jest.fn((options, cb) => {
cb(null, authResult);
});
authZero.client.userInfo = jest.fn((token, cb) => {
cb('Credentials invalid');
});
return webAuth.handleAuthentication()
.catch(() => {
expect(authZero.parseHash).toBeCalled();
expect(authZero.client.userInfo).toBeCalled();
expect(localStorage.length).toBe(0);
done();
});
});
it('clears session on logout', () => {
populateStorage();
webAuth.logout();
expect(localStorage.length).toBe(0);
});
it('indicates authenticated and expired users based on expiry time', () => {
populateStorage();
localStorage.setItem(STORAGE.EXPIRES, global.Date().getTime() - 1000);
expect(webAuth.isAuthenticated()).toBe(true);
expect(webAuth.isExpired()).toBe(true);
localStorage.setItem(STORAGE.EXPIRES, global.Date().getTime());
expect(webAuth.isAuthenticated()).toBe(true);
expect(webAuth.isExpired()).toBe(false);
localStorage.setItem(STORAGE.EXPIRES, global.Date().getTime() + 1000);
expect(webAuth.isAuthenticated()).toBe(true);
expect(webAuth.isExpired()).toBe(false);
});
});
-47
View File
@@ -1,47 +0,0 @@
const STORAGE_ACCESS = 'access_token';
const STORAGE_ID = 'id_token';
const STORAGE_EXPIRES = 'expires_at';
const STORAGE_USER = 'user_info';
const STORAGE_STATE = 'auth-state';
const STORAGE_NONCE = 'auth-nonce';
/**
* Removes all auth specific items from storage.
*/
export const clear = () => {
localStorage.removeItem(STORAGE_ACCESS);
localStorage.removeItem(STORAGE_ID);
localStorage.removeItem(STORAGE_EXPIRES);
localStorage.removeItem(STORAGE_USER);
localStorage.removeItem(STORAGE_STATE);
localStorage.removeItem(STORAGE_NONCE);
};
export const storeAuth = ({
accessToken, idToken, user, expiresAt,
}) => {
localStorage.setItem(STORAGE_ACCESS, accessToken);
localStorage.setItem(STORAGE_ID, idToken);
localStorage.setItem(STORAGE_EXPIRES, expiresAt);
localStorage.setItem(STORAGE_USER, JSON.stringify(user));
};
export const storeStateAndNonce = (state, nonce) => {
localStorage.setItem(STORAGE_STATE, JSON.stringify(state));
localStorage.setItem(STORAGE_NONCE, nonce);
};
export const getStateAndNonce = () => ({
state: localStorage.getItem(STORAGE_STATE),
nonce: localStorage.getItem(STORAGE_NONCE),
});
export const clearStateAndNonce = () => {
localStorage.removeItem(STORAGE_STATE);
localStorage.removeItem(STORAGE_NONCE);
};
export const getUserInfo = () => JSON.parse(localStorage.getItem(STORAGE_USER));
export const getIdToken = () => localStorage.getItem(STORAGE_ID);
export const getAccessToken = () => localStorage.getItem(STORAGE_ACCESS);
export const getExpiresAt = () => JSON.parse(localStorage.getItem(STORAGE_EXPIRES));
-18
View File
@@ -1,18 +0,0 @@
import AuthClient from './auth-client';
const getRedirectUri = () => {
return location ? `${location.origin}/authorize/` : "http://localhost:3000/authorize/";
};
const auth0Config = {
domain: "unbound.eu.auth0.com",
clientID: "orQfnvCPUR5C3mJkKoiWLQHOVQsBn60e",
redirectUri: getRedirectUri(),
audience: "http://dancefinder.unbound.se",
responseType: "token id_token",
scope: "openid profile email readwrite:settings"
};
const webAuth = new AuthClient(auth0Config);
export default webAuth;
-11
View File
@@ -1,11 +0,0 @@
const webAuth = require("../auth").default;
module.exports = {
includeCredentials: (uri, options) => {
const token = webAuth.accessToken();
if (token) {
options.headers['Authorization'] = 'Bearer ' + token;
}
return fetch(uri, options);
}
};
-37
View File
@@ -1,37 +0,0 @@
module.exports = {
toggleIgnoreBandMutation: `
mutation ToggleIgnoreBand($name: String!) {
ignore: ToggleIgnoreBand(name: $name)
}
`,
toggleIgnoreDanceHallMutation: `
mutation ToggleIgnoreDanceHall($name: String!) {
ignore: ToggleIgnoreDanceHall(name: $name)
}
`,
toggleIgnoreCityMutation: `
mutation ToggleIgnoreCity($name: String!) {
ignore: ToggleIgnoreCity(name: $name)
}
`,
toggleIgnoreMunicipalityMutation: `
mutation ToggleIgnoreMunicipality($name: String!) {
ignore: ToggleIgnoreMunicipality(name: $name)
}
`,
toggleIgnoreStateMutation: `
mutation ToggleIgnoreState($name: String!) {
ignore: ToggleIgnoreState(name: $name)
}
`,
saveOriginMutation: `
mutation SaveOrigin($origin: String!) {
saved: SaveOrigin(origin: $origin)
}
`,
removeOriginMutation: `
mutation RemoveOrigin($origin: String!) {
removed: RemoveOrigin(origin: $origin)
}
`
};
+36 -33
View File
@@ -1,34 +1,37 @@
import { createQuery } from './utils';
import {
toggleIgnoreBandMutation,
toggleIgnoreDanceHallMutation,
toggleIgnoreCityMutation,
toggleIgnoreMunicipalityMutation,
toggleIgnoreStateMutation,
saveOriginMutation,
removeOriginMutation
} from './mutationStrings';
import gql from 'graphql-tag'
/* eslint-disable max-len */
export const toggleIgnoreBand = variables => {
return createQuery(toggleIgnoreBandMutation, variables)
};
export const toggleIgnoreDanceHall = variables => {
return createQuery(toggleIgnoreDanceHallMutation, variables)
};
export const toggleIgnoreCity = variables => {
return createQuery(toggleIgnoreCityMutation, variables)
};
export const toggleIgnoreMunicipality = variables => {
return createQuery(toggleIgnoreMunicipalityMutation, variables)
};
export const toggleIgnoreState = variables => {
return createQuery(toggleIgnoreStateMutation, variables)
};
export const saveOrigin = variables => {
return createQuery(saveOriginMutation, variables)
};
export const removeOrigin = variables => {
return createQuery(removeOriginMutation, variables)
};
/* eslint-enable max-len */
export const toggleIgnoreBand = gql`
mutation ToggleIgnoreBand($name: String!) {
ignore: ToggleIgnoreBand(name: $name)
}
`
export const toggleIgnoreDanceHall = gql`
mutation ToggleIgnoreDanceHall($name: String!) {
ignore: ToggleIgnoreDanceHall(name: $name)
}
`
export const toggleIgnoreCity = gql`
mutation ToggleIgnoreCity($name: String!) {
ignore: ToggleIgnoreCity(name: $name)
}
`
export const toggleIgnoreMunicipality = gql`
mutation ToggleIgnoreMunicipality($name: String!) {
ignore: ToggleIgnoreMunicipality(name: $name)
}
`
export const toggleIgnoreState = gql`
mutation ToggleIgnoreState($name: String!) {
ignore: ToggleIgnoreState(name: $name)
}
`
export const saveOrigin = gql`
mutation SaveOrigin($origin: String!) {
saved: SaveOrigin(origin: $origin)
}
`
export const removeOrigin = gql`
mutation RemoveOrigin($origin: String!) {
removed: RemoveOrigin(origin: $origin)
}
`
+47 -13
View File
@@ -1,14 +1,48 @@
import { createQuery } from './utils';
import {
eventsQuery,
originsQuery,
addressFromLatLngQuery,
filtersQuery,
} from './queryStrings';
import gql from 'graphql-tag'
/* eslint-disable max-len */
export const findEvents = variables => createQuery(eventsQuery, variables);
export const findOrigins = () => createQuery(originsQuery);
export const fetchAddress = variables => createQuery(addressFromLatLngQuery, variables);
export const fetchFilters = () => createQuery(filtersQuery);
/* eslint-enable max-len */
export const findEvents = gql`
query events($range: Range $origins: [String!] $includeOrigins: Boolean!) {
events: Events(range: $range origins: $origins) {
date
time
band {
name
}
danceHall {
name
city
municipality
state
}
extraInfo
distances {
origin
distance
duration
}
}
origins: Origins @include(if: $includeOrigins)
}
`;
export const findOrigins = gql`
query origins {
origins: Origins
}
`;
export const fetchAddress = gql`
query adressFromLatLng($latlng: String!) {
address: AddressFromLatLng(latlng: $latlng)
}
`;
export const fetchFilters = gql`
query {
bands: IgnoredBands
cities: IgnoredCities
states: IgnoredStates
danceHalls: IgnoredDanceHalls
municipalities: IgnoredMunicipalities
}
`;
-50
View File
@@ -1,50 +0,0 @@
export const eventQuery = `
events: Events(range: $range origins: $origins) {
date
time
band {
name
}
danceHall {
name
city
municipality
state
}
extraInfo
distances {
origin
distance
duration
}
}
`;
export const eventsQuery = `
query events($range: Range $origins: [String!] $includeOrigins: Boolean!) {
${eventQuery}
origins: Origins @include(if: $includeOrigins)
}
`;
export const originsQuery = `
query origins {
origins: Origins
}
`;
export const addressFromLatLngQuery = `
query adressFromLatLng($latlng: String!) {
address: AddressFromLatLng(latlng: $latlng)
}
`;
export const filtersQuery = `
query {
bands: IgnoredBands
cities: IgnoredCities
states: IgnoredStates
danceHalls: IgnoredDanceHalls
municipalities: IgnoredMunicipalities
}
`;
-51
View File
@@ -1,51 +0,0 @@
import { execute, makePromise, ApolloLink, Observable } from 'apollo-link';
import { HttpLink } from 'apollo-link-http';
import gql from 'graphql-tag';
const { includeCredentials } = require('./middleware');
import { onError } from 'apollo-link-error';
import { default as webAuth} from '../auth';
const defaultGraphUri = '/query';
const httpLink = new HttpLink({ uri: defaultGraphUri, fetch: includeCredentials, credentials: 'same-origin' });
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
console.log('GraphQL errors:', graphQLErrors);
// for (let err of graphQLErrors) {
// switch (err.extensions.code) {
// case 'UNAUTHENTICATED':
// // error code is set to UNAUTHENTICATED
// // when AuthenticationError thrown in resolver
//
// // modify the operation context with a new token
// }
// }
}
if (networkError) {
if (networkError.statusCode === 401) {
return new Observable(observer => {
webAuth.checkSession(() => {
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
};
// Retry last failed request
forward(operation).subscribe(subscriber);
}, (err) => {
console.log(err);
observer.error(err)
});
});
}
}
}
);
export const createQuery = (query, variables) => { // eslint-disable-line
const operation = {
query: gql(query),
variables: variables
};
return makePromise(execute(ApolloLink.from([errorLink, httpLink]), operation));
};
+9
View File
@@ -0,0 +1,9 @@
const DARK_MODE_KEY = 'dancefinder-dark-mode'
const LOCALE_KEY = 'dancefinder-locale'
const getDarkMode = () => localStorage.getItem(DARK_MODE_KEY)
const setDarkMode = (mode) => localStorage.setItem(DARK_MODE_KEY, mode)
const getLocale = () => localStorage.getItem(LOCALE_KEY)
const setLocale = (locale) => localStorage.setItem(LOCALE_KEY, locale)
export { getDarkMode, setDarkMode, getLocale, setLocale };