accounts-frontend/packages/app/services/authFlow/AuthFlow.ts

293 lines
9.0 KiB
TypeScript
Raw Normal View History

import { browserHistory } from 'app/services/history';
import logger from 'app/services/logger';
import localStorage from 'app/services/localStorage';
import { Store, State as RootState, Dispatch } from 'app/types';
import {
activate as activateAccount,
authenticate,
logoutAll as logout,
remove as removeAccount,
} from 'app/components/accounts/actions';
import * as actions from 'app/components/auth/actions';
import { updateUser } from 'app/components/user/actions';
import FinishState from './FinishState';
2016-03-02 02:06:14 +05:30
import RegisterState from './RegisterState';
import LoginState from './LoginState';
import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState';
import InitOAuthDeviceCodeFlowState from './InitOAuthDeviceCodeFlowState';
2016-03-02 02:06:14 +05:30
import ForgotPasswordState from './ForgotPasswordState';
import RecoverPasswordState from './RecoverPasswordState';
import ActivationState from './ActivationState';
2016-08-27 15:49:02 +05:30
import CompleteState from './CompleteState';
import ChooseAccountState from './ChooseAccountState';
import ResendActivationState from './ResendActivationState';
import State from './State';
interface Request {
2020-05-24 04:38:24 +05:30
path: string;
query: URLSearchParams;
params: Record<string, any>;
}
export const availableActions = {
updateUser,
authenticate,
activateAccount,
removeAccount,
logout,
goBack: actions.goBack,
redirect: actions.redirect,
login: actions.login,
acceptRules: actions.acceptRules,
forgotPassword: actions.forgotPassword,
recoverPassword: actions.recoverPassword,
register: actions.register,
activate: actions.activate,
resendActivation: actions.resendActivation,
contactUs: actions.contactUs,
setLogin: actions.setLogin,
setAccountSwitcher: actions.setAccountSwitcher,
setErrors: actions.setErrors,
clearErrors: actions.clearErrors,
oAuthValidate: actions.oAuthValidate,
oAuthComplete: actions.oAuthComplete,
setClient: actions.setClient,
resetOAuth: actions.resetOAuth,
resetAuth: actions.resetAuth,
setOAuthRequest: actions.setOAuthRequest,
setOAuthCode: actions.setOAuthCode,
requirePermissionsAccept: actions.requirePermissionsAccept,
setScopes: actions.setScopes,
setLoadingState: actions.setLoadingState,
};
type ActionId = keyof typeof availableActions;
2017-08-23 00:09:08 +05:30
export interface AuthContext {
run<T extends ActionId>(actionId: T, payload?: Parameters<typeof availableActions[T]>[0]): Promise<any>; // TODO: can't find a way to explain to TS the returned type
setState(newState: State): Promise<void> | void; // TODO: always return promise
2020-05-24 04:38:24 +05:30
getState(): RootState;
navigate(route: string, options?: { replace?: boolean }): void;
getRequest(): Request;
prevState: State;
2017-08-23 00:09:08 +05:30
}
export default class AuthFlow implements AuthContext {
actions: Readonly<typeof availableActions>;
state: State;
prevState: State;
2020-05-24 04:38:24 +05:30
/**
* A callback from router, that allows to replace (perform redirect) route
* during route transition
*/
replace: ((path: string) => void) | null;
onReady: () => void;
navigate: (route: string, options: { replace?: boolean }) => void;
currentRequest: Partial<Request> = {};
oAuthStateRestored = false;
dispatch: Dispatch;
2020-05-24 04:38:24 +05:30
getState: () => RootState;
constructor(actions: typeof availableActions) {
2020-05-24 04:38:24 +05:30
this.actions = Object.freeze(actions);
}
2020-05-24 04:38:24 +05:30
setStore(store: Store): void {
this.navigate = (route: string, options: { replace?: boolean } = {}): void => {
const { path: currentPath } = this.getRequest();
2020-05-24 04:38:24 +05:30
if (currentPath !== route) {
if (currentPath.startsWith('/oauth2/v1') && options.replace === undefined) {
options.replace = true;
}
2016-03-02 02:06:14 +05:30
2020-05-24 04:38:24 +05:30
if (this.replace) {
this.replace(route);
}
2016-03-02 02:06:14 +05:30
if (options.replace) {
browserHistory.replace(route);
} else {
browserHistory.push(route);
}
2020-05-24 04:38:24 +05:30
}
2016-03-02 02:06:14 +05:30
2020-05-24 04:38:24 +05:30
this.replace = null;
};
2016-03-02 02:06:14 +05:30
2020-05-24 04:38:24 +05:30
this.getState = store.getState.bind(store);
this.dispatch = store.dispatch.bind(store);
}
2016-03-02 02:06:14 +05:30
resolve(payload: Record<string, any> = {}) {
2020-05-24 04:38:24 +05:30
this.state.resolve(this, payload);
}
reject(payload: Record<string, any> = {}) {
2020-05-24 04:38:24 +05:30
this.state.reject(this, payload);
}
2016-03-02 02:06:14 +05:30
2020-05-24 04:38:24 +05:30
goBack() {
this.state.goBack(this);
2016-03-02 02:06:14 +05:30
}
run<T extends ActionId>(actionId: T, payload?: Parameters<typeof availableActions[T]>[0]): Promise<any> {
// @ts-ignore the extended version of redux with thunk will return the correct promise
return Promise.resolve(this.dispatch(this.actions[actionId](payload)));
2016-03-02 02:06:14 +05:30
}
setState(state: State) {
this.state?.leave(this);
2020-05-24 04:38:24 +05:30
this.prevState = this.state;
this.state = state;
const resp = this.state.enter(this);
2020-05-24 04:38:24 +05:30
if (resp && resp.then) {
// this is a state with an async enter phase
// block route components from mounting, till promise will be resolved
if (this.onReady) {
const callback = this.onReady;
this.onReady = () => {};
2020-05-24 04:38:24 +05:30
return resp.then(callback, (error) => {
logger.error('State transition error', { error });
2020-05-24 04:38:24 +05:30
return error;
});
}
2020-05-24 04:38:24 +05:30
return resp;
}
}
2020-05-24 04:38:24 +05:30
getRequest() {
return {
path: '',
query: new URLSearchParams(),
params: {},
...this.currentRequest,
};
}
2020-05-24 04:38:24 +05:30
/**
* This should be called from onEnter prop of react-router Route component
*/
handleRequest(request: Request, replace: (path: string) => void, callback: () => void = () => {}): void {
2020-05-24 04:38:24 +05:30
const { path } = request;
this.replace = replace;
this.onReady = callback;
if (!path) {
throw new Error('The request.path is required');
}
if (this.getRequest().path === path) {
// we are already handling this path
this.onReady();
2020-05-24 04:38:24 +05:30
return;
}
2016-08-27 15:49:02 +05:30
2020-05-24 04:38:24 +05:30
this.currentRequest = request;
2020-05-24 04:38:24 +05:30
if (this.restoreOAuthState()) {
return;
}
2020-05-24 04:38:24 +05:30
switch (path) {
case '/register':
this.setState(new RegisterState());
break;
case '/forgot-password':
this.setState(new ForgotPasswordState());
break;
case '/resend-activation':
this.setState(new ResendActivationState());
break;
case '/choose-account':
this.setState(new ChooseAccountState());
break;
case '/code':
this.setState(new InitOAuthDeviceCodeFlowState());
break;
case '/oauth/finish':
this.setState(new FinishState());
break;
2020-05-24 04:38:24 +05:30
case '/':
case '/login':
case '/password':
case '/mfa':
case '/accept-rules':
case '/oauth/permissions':
case '/oauth/choose-account':
this.setState(new LoginState());
break;
default:
switch (
path.replace(/(.)\/.+/, '$1') // use only first part of an url
) {
case '/oauth2':
this.setState(new InitOAuthAuthCodeFlowState());
2020-05-24 04:38:24 +05:30
break;
case '/activation':
this.setState(new ActivationState());
break;
case '/recover-password':
this.setState(new RecoverPasswordState());
break;
default:
replace('/404');
break;
}
}
2020-05-24 04:38:24 +05:30
this.onReady();
}
2020-05-24 04:38:24 +05:30
/**
* Tries to restore last oauth request, if it was stored in localStorage
* in last 2 hours
*
* @returns {bool} - whether oauth state is being restored
*/
private restoreOAuthState(): boolean {
if (this.oAuthStateRestored) {
return false;
}
2020-05-24 04:38:24 +05:30
this.oAuthStateRestored = true;
2019-12-29 21:56:51 +05:30
2020-05-24 04:38:24 +05:30
if (/^\/(register|oauth2)/.test(this.getRequest().path)) {
// allow register or the new oauth requests
return false;
}
2020-05-24 04:38:24 +05:30
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = JSON.parse(localStorage.getItem('oauthData')!);
const expirationTime = 2 * 60 * 60 * 1000; // 2h
2016-08-27 15:49:02 +05:30
2020-05-24 04:38:24 +05:30
if (Date.now() - data.timestamp < expirationTime) {
this.run('oAuthValidate', data.payload)
.then(() => this.setState(new CompleteState()))
.then(() => this.onReady());
2020-05-24 04:38:24 +05:30
return true;
}
} catch (err) {
/* bad luck :( */
}
return false;
}
2016-03-02 02:06:14 +05:30
}