Initial commit
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import auth0 from 'auth0-js';
|
||||
import {
|
||||
storeStateAndNonce,
|
||||
clearStateAndNonce,
|
||||
getStateAndNonce,
|
||||
storeAuth,
|
||||
getIdToken,
|
||||
getUserInfo,
|
||||
clear,
|
||||
getExpiresAt,
|
||||
} 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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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 getExpiresAt = () => JSON.parse(localStorage.getItem(STORAGE_EXPIRES));
|
||||
@@ -0,0 +1,18 @@
|
||||
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: "https://unbound.eu.auth0.com/userinfo",
|
||||
responseType: "token id_token",
|
||||
scope: "openid profile"
|
||||
};
|
||||
|
||||
const webAuth = new AuthClient(auth0Config);
|
||||
|
||||
export default webAuth;
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
findEvents,
|
||||
} from '../index';
|
||||
|
||||
const verifyResponse = (response) => {
|
||||
expect(response.errors).toBe(undefined);
|
||||
};
|
||||
|
||||
const verifyError = (response) => {
|
||||
expect(response.errors.length).toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
describe('GQL Queries', () => {
|
||||
test('findEvents', () => findEvents().then(verifyResponse));
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
findEvents
|
||||
} from './queries';
|
||||
|
||||
export {
|
||||
ignoreBand,
|
||||
ignoreDanceHall,
|
||||
ignoreCity,
|
||||
ignoreMunicipality,
|
||||
ignoreState
|
||||
} from './mutations';
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
includeCredentials: (tokenFn) => {
|
||||
return ({options}, next) => {
|
||||
if (!options.headers) {
|
||||
options.headers = {}; // Create the headers object if needed.
|
||||
}
|
||||
const token = tokenFn();
|
||||
if (token) {
|
||||
options.headers['Authorization'] = 'Bearer ' + tokenFn();
|
||||
}
|
||||
options.credentials = 'same-origin'; // eslint-disable-line
|
||||
next();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
ignoreBandMutation: `
|
||||
mutation IgnoreBand($name: String!) {
|
||||
ignore: IgnoreBand(name: $name)
|
||||
}
|
||||
`,
|
||||
ignoreDanceHallMutation: `
|
||||
mutation IgnoreDanceHall($name: String!) {
|
||||
ignore: IgnoreDanceHall(name: $name)
|
||||
}
|
||||
`,
|
||||
ignoreCityMutation: `
|
||||
mutation IgnoreCity($name: String!) {
|
||||
ignore: IgnoreCity(name: $name)
|
||||
}
|
||||
`,
|
||||
ignoreMunicipalityMutation: `
|
||||
mutation IgnoreMunicipality($name: String!) {
|
||||
ignore: IgnoreMunicipality(name: $name)
|
||||
}
|
||||
`,
|
||||
ignoreStateMutation: `
|
||||
mutation IgnoreState($name: String!) {
|
||||
ignore: IgnoreState(name: $name)
|
||||
}
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createQuery } from './utils';
|
||||
import {
|
||||
ignoreBandMutation,
|
||||
ignoreDanceHallMutation,
|
||||
ignoreCityMutation,
|
||||
ignoreMunicipalityMutation,
|
||||
ignoreStateMutation,
|
||||
} from './mutationStrings';
|
||||
import webAuth from '../auth';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export const ignoreBand = variables => {
|
||||
return createQuery(webAuth.idToken, ignoreBandMutation, variables)
|
||||
};
|
||||
export const ignoreDanceHall = variables => {
|
||||
return createQuery(webAuth.idToken, ignoreDanceHallMutation, variables)
|
||||
};
|
||||
export const ignoreCity = variables => {
|
||||
return createQuery(webAuth.idToken, ignoreCityMutation, variables)
|
||||
};
|
||||
export const ignoreMunicipality = variables => {
|
||||
return createQuery(webAuth.idToken, ignoreMunicipalityMutation, variables)
|
||||
};
|
||||
export const ignoreState = variables => {
|
||||
return createQuery(webAuth.idToken, ignoreStateMutation, variables)
|
||||
};
|
||||
/* eslint-enable max-len */
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createQuery } from './utils';
|
||||
import {
|
||||
eventQuery,
|
||||
} from './queryStrings';
|
||||
import webAuth from '../auth';
|
||||
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export const findEvents = () => createQuery(webAuth.idToken, eventQuery);
|
||||
/* eslint-enable max-len */
|
||||
@@ -0,0 +1,18 @@
|
||||
export const eventQuery = `
|
||||
{
|
||||
events: Events {
|
||||
date
|
||||
time
|
||||
band {
|
||||
name
|
||||
}
|
||||
danceHall {
|
||||
name
|
||||
city
|
||||
municipality
|
||||
state
|
||||
}
|
||||
extraInfo
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,13 @@
|
||||
const { createApolloFetch } = require('apollo-fetch');
|
||||
const { includeCredentials } = require('./middleware');
|
||||
|
||||
const defaultGraphUri = '/graph';
|
||||
|
||||
export const createQuery = (tokenFn, query, variables) => { // eslint-disable-line
|
||||
const apollo = createApolloFetch({ uri: defaultGraphUri });
|
||||
|
||||
apollo.use(includeCredentials(tokenFn));
|
||||
// apollo.useAfter(trackErrors);
|
||||
|
||||
return apollo({ query, variables });
|
||||
};
|
||||
Reference in New Issue
Block a user