diff --git a/packages/app/components/auth/actions.test.ts b/packages/app/components/auth/actions.test.ts index 2aa64ca..039576e 100644 --- a/packages/app/components/auth/actions.test.ts +++ b/packages/app/components/auth/actions.test.ts @@ -16,14 +16,18 @@ import { login, setLogin, } from 'app/components/auth/actions'; -import { OauthData, OAuthValidateResponse } from '../../services/api/oauth'; +import { OAuthValidateResponse } from 'app/services/api/oauth'; -const oauthData: OauthData = { - clientId: '', - redirectUrl: '', - responseType: '', - scope: '', - state: '', +import { OAuthState } from './reducer'; + +const oauthData: OAuthState = { + params: { + clientId: '', + redirectUrl: '', + responseType: '', + scope: '', + state: '', + }, prompt: 'none', }; @@ -64,9 +68,6 @@ describe('components/auth/actions', () => { name: '', description: '', }, - oAuth: { - state: 123, - }, session: { scopes: ['account_info'], }, @@ -86,7 +87,6 @@ describe('components/auth/actions', () => { [setClient(resp.client)], [ setOAuthRequest({ - ...resp.oAuth, prompt: 'none', loginHint: undefined, }), diff --git a/packages/app/components/auth/actions.ts b/packages/app/components/auth/actions.ts index e3c9fe1..53bbdeb 100644 --- a/packages/app/components/auth/actions.ts +++ b/packages/app/components/auth/actions.ts @@ -13,7 +13,7 @@ import { recoverPassword as recoverPasswordEndpoint, OAuthResponse, } from 'app/services/api/authentication'; -import oauth, { OauthData, Scope } from 'app/services/api/oauth'; +import oauth, { OauthRequestData, Scope } from 'app/services/api/oauth'; import { register as registerEndpoint, activate as activateEndpoint, @@ -314,38 +314,17 @@ const KNOWN_SCOPES: ReadonlyArray<string> = [ 'account_info', 'account_email', ]; -/** - * @param {object} oauthData - * @param {string} oauthData.clientId - * @param {string} oauthData.redirectUrl - * @param {string} oauthData.responseType - * @param {string} oauthData.description - * @param {string} oauthData.scope - * @param {string} [oauthData.prompt='none'] - comma-separated list of values to adjust auth flow - * Posible values: - * * none - default behaviour - * * consent - forcibly prompt user for rules acceptance - * * select_account - force account choosage, even if user has only one - * @param {string} oauthData.loginHint - allows to choose the account, which will be used for auth - * The possible values: account id, email, username - * @param {string} oauthData.state - * - * @returns {Promise} - */ -export function oAuthValidate(oauthData: OauthData) { + +export function oAuthValidate(oauthData: Pick<OAuthState, 'params' | 'description' | 'prompt' | 'loginHint'>) { // TODO: move to oAuth actions? - // test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo + // auth code flow: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo + // device code flow: /code?user_code=XOXOXOXO return wrapInLoader((dispatch) => oauth - .validate(oauthData) + .validate(getOAuthRequest(oauthData)) .then((resp) => { const { scopes } = resp.session; const invalidScopes = scopes.filter((scope) => !KNOWN_SCOPES.includes(scope)); - let prompt = (oauthData.prompt || 'none').split(',').map((item) => item.trim()); - - if (prompt.includes('none')) { - prompt = ['none']; - } if (invalidScopes.length) { logger.error('Got invalid scopes after oauth validation', { @@ -353,12 +332,19 @@ export function oAuthValidate(oauthData: OauthData) { }); } + let { prompt } = oauthData; + + if (prompt && !Array.isArray(prompt)) { + prompt = prompt.split(',').map((item) => item.trim()); + } + dispatch(setClient(resp.client)); dispatch( setOAuthRequest({ - ...resp.oAuth, - prompt: oauthData.prompt || 'none', + params: oauthData.params, + description: oauthData.description, loginHint: oauthData.loginHint, + prompt, }), ); dispatch(setScopes(scopes)); @@ -375,12 +361,6 @@ export function oAuthValidate(oauthData: OauthData) { ); } -/** - * @param {object} params - * @param {bool} params.accept=false - * @param params.accept - * @returns {Promise} - */ export function oAuthComplete(params: { accept?: boolean } = {}) { return wrapInLoader( async ( @@ -388,7 +368,7 @@ export function oAuthComplete(params: { accept?: boolean } = {}) { getState, ): Promise<{ success: boolean; - redirectUri: string; + redirectUri?: string; }> => { const oauthData = getState().auth.oauth; @@ -397,11 +377,14 @@ export function oAuthComplete(params: { accept?: boolean } = {}) { } try { - const resp = await oauth.complete(oauthData, params); + const resp = await oauth.complete(getOAuthRequest(oauthData), params); + localStorage.removeItem('oauthData'); - if (resp.redirectUri.startsWith('static_page')) { - const displayCode = /static_page_with_code/.test(resp.redirectUri); + if (!resp.redirectUri) { + dispatch(setOAuthCode({ success: resp.success && params.accept })); + } else if (resp.redirectUri.startsWith('static_page')) { + const displayCode = resp.redirectUri.includes('static_page_with_code'); const [, code] = resp.redirectUri.match(/code=(.+)&/) || []; [, resp.redirectUri] = resp.redirectUri.match(/^(.+)\?/) || []; @@ -437,13 +420,41 @@ export function oAuthComplete(params: { accept?: boolean } = {}) { ); } +function getOAuthRequest({ params, description }: OAuthState): OauthRequestData { + let data: OauthRequestData; + + if ('userCode' in params) { + data = { + user_code: params.userCode, + }; + } else { + data = { + client_id: params.clientId, + redirect_uri: params.redirectUrl, + response_type: params.responseType, + scope: params.scope, + state: params.state, + }; + } + + if (description) { + data.description = description; + } + + return data; +} + function handleOauthParamsValidation( resp: { [key: string]: any; userMessage?: string; } = {}, ) { - dispatchBsod(); + // TODO: it would be better to dispatch BSOD from the initial request performers + if (resp.error !== 'invalid_user_code') { + dispatchBsod(); + } + localStorage.removeItem('oauthData'); // eslint-disable-next-line no-alert @@ -468,33 +479,16 @@ export type ClientAction = SetClientAction; interface SetOauthAction extends ReduxAction { type: 'set_oauth'; - payload: Pick<OAuthState, 'clientId' | 'redirectUrl' | 'responseType' | 'scope' | 'prompt' | 'loginHint' | 'state'>; + payload: OAuthState | null; } // Input data is coming right from the query string, so the names // are the same, as used for initializing OAuth2 request -export function setOAuthRequest(data: { - client_id?: string; - redirect_uri?: string; - response_type?: string; - scope?: string; - prompt?: string; - loginHint?: string; - state?: string; -}): SetOauthAction { +// TODO: filter out allowed properties +export function setOAuthRequest(payload: OAuthState | null): SetOauthAction { return { type: 'set_oauth', - payload: { - // TODO: there is too much default empty string. Maybe we can somehow validate it - // on the level, where this action is called? - clientId: data.client_id || '', - redirectUrl: data.redirect_uri || '', - responseType: data.response_type || '', - scope: data.scope || '', - prompt: data.prompt || '', - loginHint: data.loginHint || '', - state: data.state || '', - }, + payload, }; } @@ -503,9 +497,7 @@ interface SetOAuthResultAction extends ReduxAction { payload: Pick<OAuthState, 'success' | 'code' | 'displayCode'>; } -export const SET_OAUTH_RESULT = 'set_oauth_result'; // TODO: remove - -export function setOAuthCode(payload: { success: boolean; code: string; displayCode: boolean }): SetOAuthResultAction { +export function setOAuthCode(payload: Pick<OAuthState, 'success' | 'code' | 'displayCode'>): SetOAuthResultAction { return { type: 'set_oauth_result', payload, @@ -515,7 +507,7 @@ export function setOAuthCode(payload: { success: boolean; code: string; displayC export function resetOAuth(): AppAction { return (dispatch): void => { localStorage.removeItem('oauthData'); - dispatch(setOAuthRequest({})); + dispatch(setOAuthRequest(null)); }; } diff --git a/packages/app/components/auth/deviceCode/DeviceCode.tsx b/packages/app/components/auth/deviceCode/DeviceCode.tsx new file mode 100644 index 0000000..5c2ec45 --- /dev/null +++ b/packages/app/components/auth/deviceCode/DeviceCode.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { FormattedMessage as Message, defineMessages } from 'react-intl'; + +import factory from '../factory'; +import Body from './DeviceCodeBody'; + +const messages = defineMessages({ + deviceCodeTitle: 'Device code', +}); + +export default factory({ + title: messages.deviceCodeTitle, + body: Body, + footer: { + color: 'green', + children: <Message key="continueButton" defaultMessage="Continue" />, + }, +}); diff --git a/packages/app/components/auth/deviceCode/DeviceCodeBody.tsx b/packages/app/components/auth/deviceCode/DeviceCodeBody.tsx new file mode 100644 index 0000000..4c64884 --- /dev/null +++ b/packages/app/components/auth/deviceCode/DeviceCodeBody.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { defineMessages } from 'react-intl'; + +import { Input } from 'app/components/ui/form'; +import BaseAuthBody from 'app/components/auth/BaseAuthBody'; + +const messages = defineMessages({ + deviceCode: 'Device code', +}); + +export default class DeviceCodeBody extends BaseAuthBody { + static displayName = 'DeviceCodeBody'; + static panelId = 'deviceCode'; + + autoFocusField = 'user_code'; + + render() { + return ( + <div> + {this.renderErrors()} + + <Input + {...this.bindField('user_code')} + icon="key" + required + placeholder={messages.deviceCode} + /> + </div> + ); + } +} diff --git a/packages/app/components/auth/deviceCode/index.ts b/packages/app/components/auth/deviceCode/index.ts new file mode 100644 index 0000000..38c2691 --- /dev/null +++ b/packages/app/components/auth/deviceCode/index.ts @@ -0,0 +1 @@ +export { default } from './DeviceCode'; diff --git a/packages/app/components/auth/finish/Finish.tsx b/packages/app/components/auth/finish/Finish.tsx index cd875f9..d411e85 100644 --- a/packages/app/components/auth/finish/Finish.tsx +++ b/packages/app/components/auth/finish/Finish.tsx @@ -1,8 +1,9 @@ -import React, { MouseEventHandler } from 'react'; +import React, { FC, MouseEventHandler, useEffect } from 'react'; +import { Redirect } from 'react-router-dom'; import { FormattedMessage as Message } from 'react-intl'; import { Helmet } from 'react-helmet-async'; -import { connect } from 'app/functions'; +import { useReduxSelector } from 'app/functions'; import { Button } from 'app/components/ui/form'; import copy from 'app/services/copy'; @@ -16,102 +17,93 @@ interface Props { success?: boolean; } -class Finish extends React.Component<Props> { - render() { - const { appName, code, state, displayCode, success } = this.props; - const authData = JSON.stringify({ - auth_code: code, - state, +const Finish: FC<Props> = () => { + const { client, oauth } = useReduxSelector((state) => state.auth); + + const onCopyClick: MouseEventHandler = (event) => { + event.preventDefault(); + copy(oauth!.code!); + }; + + let authData: string | undefined; + + if (oauth && 'state' in oauth.params) { + authData = JSON.stringify({ + auth_code: oauth.code, + state: oauth.params.state, }); + } - history.pushState(null, document.title, `#${authData}`); + useEffect(() => { + if (authData) { + history.pushState(null, document.title, `#${authData}`); + } + }, []); - return ( - <div className={styles.finishPage}> - <Helmet title={authData} /> + if (!client || !oauth) { + return <Redirect to="/" />; + } - {success ? ( - <div> - <div className={styles.successBackground} /> - <div className={styles.greenTitle}> - <Message - key="authForAppSuccessful" - defaultMessage="Authorization for {appName} was successfully completed" - values={{ - appName: <span className={styles.appName}>{appName}</span>, - }} - /> - </div> - {displayCode ? ( - <div data-testid="oauth-code-container"> - <div className={styles.description}> - <Message - key="passCodeToApp" - defaultMessage="To complete authorization process, please, provide the following code to {appName}" - values={{ appName }} - /> - </div> - <div className={styles.codeContainer}> - <div className={styles.code}>{code}</div> - </div> - <Button color="green" small onClick={this.onCopyClick}> - <Message key="copy" defaultMessage="Copy" /> - </Button> - </div> - ) : ( + return ( + <div className={styles.finishPage}> + {authData && <Helmet title={authData} />} + + {oauth.success ? ( + <div> + <div className={styles.successBackground} /> + <div className={styles.greenTitle}> + <Message + key="authForAppSuccessful" + defaultMessage="Authorization for {appName} was successfully completed" + values={{ + appName: <span className={styles.appName}>{client.name}</span>, + }} + /> + </div> + {oauth.displayCode ? ( + <div data-testid="oauth-code-container"> <div className={styles.description}> <Message - key="waitAppReaction" - defaultMessage="Please, wait till your application response" + key="passCodeToApp" + defaultMessage="To complete authorization process, please, provide the following code to {appName}" + values={{ appName: client.name }} /> </div> - )} - </div> - ) : ( - <div> - <div className={styles.failBackground} /> - <div className={styles.redTitle}> - <Message - key="authForAppFailed" - defaultMessage="Authorization for {appName} was failed" - values={{ - appName: <span className={styles.appName}>{appName}</span>, - }} - /> + <div className={styles.codeContainer}> + <div className={styles.code}>{oauth.code}</div> + </div> + <Button color="green" small onClick={onCopyClick}> + <Message key="copy" defaultMessage="Copy" /> + </Button> </div> + ) : ( <div className={styles.description}> <Message key="waitAppReaction" defaultMessage="Please, wait till your application response" /> </div> + )} + </div> + ) : ( + <div> + <div className={styles.failBackground} /> + <div className={styles.redTitle}> + <Message + key="authForAppFailed" + defaultMessage="Authorization for {appName} was failed" + values={{ + appName: <span className={styles.appName}>{client.name}</span>, + }} + /> </div> - )} - </div> - ); - } + <div className={styles.description}> + <Message key="waitAppReaction" defaultMessage="Please, wait till your application response" /> + </div> + </div> + )} + </div> + ); +}; - onCopyClick: MouseEventHandler = (event) => { - event.preventDefault(); - - const { code } = this.props; - - if (code) { - copy(code); - } - }; -} - -export default connect(({ auth }) => { - if (!auth || !auth.client || !auth.oauth) { - throw new Error('Can not connect Finish component. No auth data in state'); - } - - return { - appName: auth.client.name, - code: auth.oauth.code, - displayCode: auth.oauth.displayCode, - state: auth.oauth.state, - success: auth.oauth.success, - }; -})(Finish); +export default Finish; diff --git a/packages/app/components/auth/finish/index.ts b/packages/app/components/auth/finish/index.ts new file mode 100644 index 0000000..a5d2a36 --- /dev/null +++ b/packages/app/components/auth/finish/index.ts @@ -0,0 +1 @@ +export { default } from './Finish'; diff --git a/packages/app/components/auth/reducer.ts b/packages/app/components/auth/reducer.ts index 1862399..af398b0 100644 --- a/packages/app/components/auth/reducer.ts +++ b/packages/app/components/auth/reducer.ts @@ -38,15 +38,34 @@ export interface Client { description: string; } -export interface OAuthState { +export interface OauthAuthCodeFlowParams { clientId: string; redirectUrl: string; responseType: string; - description?: string; - scope: string; - prompt: string; - loginHint: string; state: string; + scope: string; +} + +export interface OauthDeviceCodeFlowParams { + userCode: string; +} + +export interface OAuthState { + params: OauthAuthCodeFlowParams | OauthDeviceCodeFlowParams; + description?: string; + /** + * Possible values: + * - none - default behaviour + * - consent - forcibly prompt user for rules acceptance + * - select_account - force account choosage, even if user has only one + * comma separated list of 'none' | 'consent' | 'select_account'; + */ + prompt?: string | Array<string>; + /** + * Allows to choose the account, which will be used for auth + * The possible values: account id, email, username + */ + loginHint?: string; success?: boolean; code?: string; displayCode?: boolean; diff --git a/packages/app/containers/AuthFlowRoute.tsx b/packages/app/containers/AuthFlowRoute.tsx index 4058da3..78e777f 100644 --- a/packages/app/containers/AuthFlowRoute.tsx +++ b/packages/app/containers/AuthFlowRoute.tsx @@ -1,19 +1,18 @@ -import React from 'react'; +import React, { FC } from 'react'; import { Route, RouteProps } from 'react-router-dom'; import AuthFlowRouteContents from './AuthFlowRouteContents'; -export default function AuthFlowRoute(props: RouteProps) { - const { component: Component, ...routeProps } = props; - - if (!Component) { - throw new Error('props.component required'); - } +// Make "component" prop required +type Props = Omit<RouteProps, 'component'> & Required<Pick<RouteProps, 'component'>>; +const AuthFlowRoute: FC<Props> = ({ component: Component, ...props }) => { return ( <Route - {...routeProps} + {...props} render={(routerProps) => <AuthFlowRouteContents routerProps={routerProps} component={Component} />} /> ); -} +}; + +export default AuthFlowRoute; diff --git a/packages/app/containers/AuthFlowRouteContents.tsx b/packages/app/containers/AuthFlowRouteContents.tsx index 2adcd00..f59d53a 100644 --- a/packages/app/containers/AuthFlowRouteContents.tsx +++ b/packages/app/containers/AuthFlowRouteContents.tsx @@ -4,7 +4,7 @@ import { Redirect, RouteComponentProps } from 'react-router-dom'; import authFlow from 'app/services/authFlow'; interface Props { - component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>; + component: React.ComponentType<RouteComponentProps> | React.ComponentType<any>; routerProps: RouteComponentProps; } @@ -74,7 +74,7 @@ export default class AuthFlowRouteContents extends React.Component<Props, State> }); } - onRouteAllowed(props: Props) { + onRouteAllowed(props: Props): void { if (!this.mounted) { return; } diff --git a/packages/app/pages/auth/AuthPage.tsx b/packages/app/pages/auth/AuthPage.tsx index a97dc58..4f0a9b3 100644 --- a/packages/app/pages/auth/AuthPage.tsx +++ b/packages/app/pages/auth/AuthPage.tsx @@ -1,4 +1,4 @@ -import React, { ComponentType, ReactNode, useCallback, useState } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import { Route, Switch, Redirect, RouteComponentProps } from 'react-router-dom'; import AppInfo from 'app/components/auth/appInfo/AppInfo'; @@ -7,6 +7,7 @@ import Register from 'app/components/auth/register/Register'; import Login from 'app/components/auth/login/Login'; import Permissions from 'app/components/auth/permissions/Permissions'; import ChooseAccount from 'app/components/auth/chooseAccount/ChooseAccount'; +import DeviceCode from 'app/components/auth/deviceCode'; import Activation from 'app/components/auth/activation/Activation'; import ResendActivation from 'app/components/auth/resendActivation/ResendActivation'; import Password from 'app/components/auth/password/Password'; @@ -14,7 +15,7 @@ import AcceptRules from 'app/components/auth/acceptRules/AcceptRules'; import ForgotPassword from 'app/components/auth/forgotPassword/ForgotPassword'; import RecoverPassword from 'app/components/auth/recoverPassword/RecoverPassword'; import Mfa from 'app/components/auth/mfa/Mfa'; -import Finish from 'app/components/auth/finish/Finish'; +import Finish from 'app/components/auth/finish'; import { useReduxSelector } from 'app/functions'; import { Factory } from 'app/components/auth/factory'; @@ -27,7 +28,7 @@ import styles from './auth.scss'; // so that it persist disregarding remounts let isSidebarHiddenCache = false; -const AuthPage: ComponentType = () => { +const AuthPage: FC = () => { const [isSidebarHidden, setIsSidebarHidden] = useState<boolean>(isSidebarHiddenCache); const client = useReduxSelector((state) => state.auth.client); @@ -54,6 +55,7 @@ const AuthPage: ComponentType = () => { <Route path="/choose-account" render={renderPanelTransition(ChooseAccount)} /> <Route path="/oauth/choose-account" render={renderPanelTransition(ChooseAccount)} /> <Route path="/oauth/finish" component={Finish} /> + <Route path="/code" component={renderPanelTransition(DeviceCode)} /> <Route path="/accept-rules" render={renderPanelTransition(AcceptRules)} /> <Route path="/forgot-password" render={renderPanelTransition(ForgotPassword)} /> <Route path="/recover-password/:key?" render={renderPanelTransition(RecoverPassword)} /> @@ -64,7 +66,7 @@ const AuthPage: ComponentType = () => { ); }; -function renderPanelTransition(factory: Factory): (props: RouteComponentProps<any>) => ReactNode { +function renderPanelTransition(factory: Factory): FC<RouteComponentProps> { const { Title, Body, Footer, Links } = factory(); return (props) => ( diff --git a/packages/app/services/api/oauth.ts b/packages/app/services/api/oauth.ts index abae245..634b110 100644 --- a/packages/app/services/api/oauth.ts +++ b/packages/app/services/api/oauth.ts @@ -24,35 +24,27 @@ export interface OauthAppResponse { minecraftServerIp?: string; } -interface OauthRequestData { +interface AuthCodeFlowRequestData { client_id: string; redirect_uri: string; response_type: string; - description?: string; scope: string; - prompt: string; - login_hint?: string; state?: string; } -export interface OauthData { - clientId: string; - redirectUrl: string; - responseType: string; - description?: string; - scope: string; - // TODO: why prompt is not nullable? - prompt: string; // comma separated list of 'none' | 'consent' | 'select_account'; - loginHint?: string; - state?: string; +interface DeviceCodeFlowRequestData { + user_code: string; } +export type OauthRequestData = (AuthCodeFlowRequestData | DeviceCodeFlowRequestData) & { + description?: string; +}; + export interface OAuthValidateResponse { session: { scopes: Scope[]; }; client: Client; - oAuth: {}; // TODO: improve typing } interface FormPayloads { @@ -64,20 +56,20 @@ interface FormPayloads { } const api = { - validate(oauthData: OauthData) { + validate(oauthData: OauthRequestData) { return request - .get<OAuthValidateResponse>('/api/oauth2/v1/validate', getOAuthRequest(oauthData)) + .get<OAuthValidateResponse>('/api/oauth2/v1/validate', oauthData) .catch(handleOauthParamsValidation); }, complete( - oauthData: OauthData, + oauthData: OauthRequestData, params: { accept?: boolean } = {}, ): Promise<{ success: boolean; - redirectUri: string; + redirectUri?: string; }> { - const query = request.buildQuery(getOAuthRequest(oauthData)); + const query = request.buildQuery(oauthData); return request .post<{ @@ -146,37 +138,18 @@ if ('Cypress' in window) { export default api; -/** - * @param {object} oauthData - * @param {string} oauthData.clientId - * @param {string} oauthData.redirectUrl - * @param {string} oauthData.responseType - * @param {string} oauthData.description - * @param {string} oauthData.scope - * @param {string} oauthData.state - * - * @returns {object} - */ -function getOAuthRequest(oauthData: OauthData): OauthRequestData { - return { - client_id: oauthData.clientId, - redirect_uri: oauthData.redirectUrl, - response_type: oauthData.responseType, - description: oauthData.description, - scope: oauthData.scope, - prompt: oauthData.prompt, - login_hint: oauthData.loginHint, - state: oauthData.state, - }; -} - function handleOauthParamsValidation( resp: - | { [key: string]: any } + | Record<string, any> | { statusCode: number; success: false; - error: 'invalid_request' | 'unsupported_response_type' | 'invalid_scope' | 'invalid_client'; + error: + | 'invalid_request' + | 'unsupported_response_type' + | 'invalid_scope' + | 'invalid_client' + | 'invalid_user_code'; parameter: string; } = {}, ) { diff --git a/packages/app/services/authFlow/AuthFlow.test.ts b/packages/app/services/authFlow/AuthFlow.test.ts index ab7a2f5..5b92da3 100644 --- a/packages/app/services/authFlow/AuthFlow.test.ts +++ b/packages/app/services/authFlow/AuthFlow.test.ts @@ -5,7 +5,7 @@ import AuthFlow from 'app/services/authFlow/AuthFlow'; import AbstractState from 'app/services/authFlow/AbstractState'; import localStorage from 'app/services/localStorage'; -import OAuthState from 'app/services/authFlow/OAuthState'; +import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState'; import RegisterState from 'app/services/authFlow/RegisterState'; import RecoverPasswordState from 'app/services/authFlow/RecoverPasswordState'; import ForgotPasswordState from 'app/services/authFlow/ForgotPasswordState'; @@ -314,9 +314,9 @@ describe('AuthFlow', () => { '/oauth/permissions': LoginState, '/oauth/choose-account': LoginState, '/oauth/finish': LoginState, - '/oauth2/v1/foo': OAuthState, - '/oauth2/v1': OAuthState, - '/oauth2': OAuthState, + '/oauth2/v1/foo': InitOAuthAuthCodeFlowState, + '/oauth2/v1': InitOAuthAuthCodeFlowState, + '/oauth2': InitOAuthAuthCodeFlowState, '/register': RegisterState, '/choose-account': ChooseAccountState, '/recover-password': RecoverPasswordState, @@ -379,7 +379,7 @@ describe('AuthFlow', () => { flow.handleRequest({ path, query: new URLSearchParams({}), params: {} }, () => {}, callback); expect(flow.setState, 'was called once'); - expect(flow.setState, 'to have a call satisfying', [expect.it('to be a', OAuthState)]); + expect(flow.setState, 'to have a call satisfying', [expect.it('to be a', InitOAuthAuthCodeFlowState)]); expect(callback, 'was called twice'); }); diff --git a/packages/app/services/authFlow/AuthFlow.ts b/packages/app/services/authFlow/AuthFlow.ts index cc4e1bb..9c11d22 100644 --- a/packages/app/services/authFlow/AuthFlow.ts +++ b/packages/app/services/authFlow/AuthFlow.ts @@ -10,10 +10,12 @@ import { } from 'app/components/accounts/actions'; import * as actions from 'app/components/auth/actions'; import { updateUser } from 'app/components/user/actions'; +import FinishState from './FinishState'; import RegisterState from './RegisterState'; import LoginState from './LoginState'; -import OAuthState from './OAuthState'; +import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState'; +import InitOAuthDeviceCodeFlowState from './InitOAuthDeviceCodeFlowState'; import ForgotPasswordState from './ForgotPasswordState'; import RecoverPasswordState from './RecoverPasswordState'; import ActivationState from './ActivationState'; @@ -22,11 +24,11 @@ import ChooseAccountState from './ChooseAccountState'; import ResendActivationState from './ResendActivationState'; import State from './State'; -type Request = { +interface Request { path: string; query: URLSearchParams; params: Record<string, any>; -}; +} export const availableActions = { updateUser, @@ -104,7 +106,11 @@ export default class AuthFlow implements AuthContext { this.replace(route); } - browserHistory[options.replace ? 'replace' : 'push'](route); + if (options.replace) { + browserHistory.replace(route); + } else { + browserHistory.push(route); + } } this.replace = null; @@ -132,11 +138,7 @@ export default class AuthFlow implements AuthContext { } setState(state: State) { - if (!state) { - throw new Error('State is required'); - } - - this.state && this.state.leave(this); + this.state?.leave(this); this.prevState = this.state; this.state = state; const resp = this.state.enter(this); @@ -170,16 +172,8 @@ export default class AuthFlow implements AuthContext { /** * This should be called from onEnter prop of react-router Route component - * - * @param {object} request - * @param {string} request.path - * @param {object} request.params - * @param {URLSearchParams} request.query - * @param {Function} replace - * @param {Function} [callback=function() {}] - an optional callback function to be called, when state will be stabilized - * (state's enter function's promise resolved) */ - handleRequest(request: Request, replace: (path: string) => void, callback: () => void = () => {}) { + handleRequest(request: Request, replace: (path: string) => void, callback: () => void = () => {}): void { const { path } = request; this.replace = replace; this.onReady = callback; @@ -218,13 +212,20 @@ export default class AuthFlow implements AuthContext { this.setState(new ChooseAccountState()); break; + case '/code': + this.setState(new InitOAuthDeviceCodeFlowState()); + break; + + case '/oauth/finish': + this.setState(new FinishState()); + break; + case '/': case '/login': case '/password': case '/mfa': case '/accept-rules': case '/oauth/permissions': - case '/oauth/finish': case '/oauth/choose-account': this.setState(new LoginState()); break; @@ -234,7 +235,7 @@ export default class AuthFlow implements AuthContext { path.replace(/(.)\/.+/, '$1') // use only first part of an url ) { case '/oauth2': - this.setState(new OAuthState()); + this.setState(new InitOAuthAuthCodeFlowState()); break; case '/activation': this.setState(new ActivationState()); diff --git a/packages/app/services/authFlow/CompleteState.ts b/packages/app/services/authFlow/CompleteState.ts index 141aa9f..5ab296d 100644 --- a/packages/app/services/authFlow/CompleteState.ts +++ b/packages/app/services/authFlow/CompleteState.ts @@ -1,4 +1,6 @@ +import { OAuthState } from 'app/components/auth/reducer'; import { getActiveAccount } from 'app/components/accounts/reducer'; + import AbstractState from './AbstractState'; import LoginState from './LoginState'; import PermissionsState from './PermissionsState'; @@ -11,8 +13,16 @@ import { AuthContext } from './AuthFlow'; const PROMPT_ACCOUNT_CHOOSE = 'select_account'; const PROMPT_PERMISSIONS = 'consent'; +function hasPrompt(prompt: OAuthState['prompt'], needle: string): boolean { + if (Array.isArray(prompt)) { + return prompt.includes(needle); + } + + return prompt === needle; +} + export default class CompleteState extends AbstractState { - isPermissionsAccepted: boolean | void; + private readonly isPermissionsAccepted?: boolean; constructor( options: { @@ -36,7 +46,7 @@ export default class CompleteState extends AbstractState { context.setState(new LoginState()); } else if (user.shouldAcceptRules && !user.isDeleted) { context.setState(new AcceptRulesState()); - } else if (oauth && oauth.clientId) { + } else if (oauth?.params) { return this.processOAuth(context); } else { context.navigate('/'); @@ -44,6 +54,8 @@ export default class CompleteState extends AbstractState { } processOAuth(context: AuthContext): Promise<void> | void { + console.log('process oauth', this.isPermissionsAccepted); + const { auth, accounts, user } = context.getState(); let { isSwitcherEnabled } = auth; @@ -80,7 +92,7 @@ export default class CompleteState extends AbstractState { // so that they can see, that their account was deleted // (this info is displayed on switcher) user.isDeleted || - oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE)) + hasPrompt(oauth.prompt, PROMPT_ACCOUNT_CHOOSE)) ) { context.setState(new ChooseAccountState()); } else if (user.isDeleted) { @@ -92,35 +104,35 @@ export default class CompleteState extends AbstractState { } else if (oauth.code) { context.setState(new FinishState()); } else { - const data: { [key: string]: any } = {}; + const data: Record<string, any> = {}; if (typeof this.isPermissionsAccepted !== 'undefined') { data.accept = this.isPermissionsAccepted; - } else if (oauth.acceptRequired || oauth.prompt.includes(PROMPT_PERMISSIONS)) { + } else if (oauth.acceptRequired || hasPrompt(oauth.prompt, PROMPT_PERMISSIONS)) { context.setState(new PermissionsState()); return; } // TODO: it seems that oAuthComplete may be a separate state - return context.run('oAuthComplete', data).then( - (resp: { redirectUri: string }) => { + return context + .run('oAuthComplete', data) + .then((resp: { redirectUri?: string }) => { // TODO: пусть в стейт попадает флаг или тип авторизации // вместо волшебства над редирект урлой - if (resp.redirectUri.includes('static_page')) { + if (!resp.redirectUri || resp.redirectUri.includes('static_page')) { context.setState(new FinishState()); } else { return context.run('redirect', resp.redirectUri); } - }, - (resp) => { + }) + .catch((resp) => { if (resp.unauthorized) { context.setState(new LoginState()); } else if (resp.acceptRequired) { context.setState(new PermissionsState()); } - }, - ); + }); } } } diff --git a/packages/app/services/authFlow/DeviceCodeState.ts b/packages/app/services/authFlow/DeviceCodeState.ts new file mode 100644 index 0000000..4d80169 --- /dev/null +++ b/packages/app/services/authFlow/DeviceCodeState.ts @@ -0,0 +1,26 @@ +import AbstractState from './AbstractState'; +import { AuthContext } from './AuthFlow'; +import CompleteState from './CompleteState'; + +export default class DeviceCodeState extends AbstractState { + async resolve(context: AuthContext, payload: { user_code: string }): Promise<void> { + const { query } = context.getRequest(); + + context + .run('oAuthValidate', { + params: { + userCode: payload.user_code, + }, + description: query.get('description')!, + prompt: query.get('prompt')!, + }) + .then(() => context.setState(new CompleteState())) + .catch((err) => { + if (err.error === 'invalid_user_code') { + return context.run('setErrors', { [err.parameter]: err.error }); + } + + throw err; + }); + } +} diff --git a/packages/app/services/authFlow/OAuthState.test.ts b/packages/app/services/authFlow/InitOAuthAuthCodeFlowState.test.ts similarity index 96% rename from packages/app/services/authFlow/OAuthState.test.ts rename to packages/app/services/authFlow/InitOAuthAuthCodeFlowState.test.ts index 1ae70ae..bf1ba78 100644 --- a/packages/app/services/authFlow/OAuthState.test.ts +++ b/packages/app/services/authFlow/InitOAuthAuthCodeFlowState.test.ts @@ -1,17 +1,17 @@ import sinon, { SinonMock } from 'sinon'; -import OAuthState from 'app/services/authFlow/OAuthState'; +import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState'; import CompleteState from 'app/services/authFlow/CompleteState'; import { bootstrap, expectState, expectRun, MockedAuthContext } from './helpers'; describe('OAuthState', () => { - let state: OAuthState; + let state: InitOAuthAuthCodeFlowState; let context: MockedAuthContext; let mock: SinonMock; beforeEach(() => { - state = new OAuthState(); + state = new InitOAuthAuthCodeFlowState(); const data = bootstrap(); context = data.context; diff --git a/packages/app/services/authFlow/OAuthState.ts b/packages/app/services/authFlow/InitOAuthAuthCodeFlowState.ts similarity index 55% rename from packages/app/services/authFlow/OAuthState.ts rename to packages/app/services/authFlow/InitOAuthAuthCodeFlowState.ts index fef2fab..a1bb88b 100644 --- a/packages/app/services/authFlow/OAuthState.ts +++ b/packages/app/services/authFlow/InitOAuthAuthCodeFlowState.ts @@ -2,20 +2,22 @@ import AbstractState from './AbstractState'; import { AuthContext } from './AuthFlow'; import CompleteState from './CompleteState'; -export default class OAuthState extends AbstractState { +export default class InitOAuthAuthCodeFlowState extends AbstractState { enter(context: AuthContext): Promise<void> | void { const { query, params } = context.getRequest(); return context .run('oAuthValidate', { - clientId: query.get('client_id') || params.clientId, - redirectUrl: query.get('redirect_uri')!, - responseType: query.get('response_type')!, + params: { + clientId: query.get('client_id') || params.clientId, + redirectUrl: query.get('redirect_uri')!, + responseType: query.get('response_type')!, + scope: (query.get('scope') || '').replace(/,/g, ' '), + state: query.get('state')!, + }, description: query.get('description')!, - scope: (query.get('scope') || '').replace(/,/g, ' '), prompt: query.get('prompt')!, loginHint: query.get('login_hint')!, - state: query.get('state')!, }) .then(() => context.setState(new CompleteState())); } diff --git a/packages/app/services/authFlow/InitOAuthDeviceCodeFlowState.ts b/packages/app/services/authFlow/InitOAuthDeviceCodeFlowState.ts new file mode 100644 index 0000000..022f1db --- /dev/null +++ b/packages/app/services/authFlow/InitOAuthDeviceCodeFlowState.ts @@ -0,0 +1,27 @@ +import AbstractState from './AbstractState'; +import { AuthContext } from './AuthFlow'; +import CompleteState from './CompleteState'; +import DeviceCodeState from './DeviceCodeState'; + +export default class InitOAuthDeviceCodeFlowState extends AbstractState { + async enter(context: AuthContext): Promise<void> { + const { query } = context.getRequest(); + + const userCode = query.get('user_code'); + + if (userCode) { + try { + await context.run('oAuthValidate', { + params: { userCode }, + description: query.get('description')!, + prompt: query.get('prompt')!, + }); + await context.setState(new CompleteState()); + } catch { + // Ok, fallback to the default + } + } + + return context.setState(new DeviceCodeState()); + } +} diff --git a/packages/app/services/errorsDict/errorsDict.tsx b/packages/app/services/errorsDict/errorsDict.tsx index 7095cf5..12fdad6 100644 --- a/packages/app/services/errorsDict/errorsDict.tsx +++ b/packages/app/services/errorsDict/errorsDict.tsx @@ -127,6 +127,8 @@ const errorsMap: Record<string, (props?: Record<string, any>) => ReactElement> = 'error.redirectUri_required': () => <Message key="redirectUriRequired" defaultMessage="Redirect URI is required" />, 'error.redirectUri_invalid': () => <Message key="redirectUriInvalid" defaultMessage="Redirect URI is invalid" />, + + 'invalid_user_code': () => <Message key="invalidUserCode" defaultMessage="Invalid Device Code" />, }; interface ErrorLiteral {