Initial device code flow implementation

This commit is contained in:
ErickSkrauch
2024-12-10 20:42:06 +01:00
parent 533849026d
commit 3f0565e26b
20 changed files with 370 additions and 272 deletions

View File

@@ -16,14 +16,18 @@ import {
login, login,
setLogin, setLogin,
} from 'app/components/auth/actions'; } from 'app/components/auth/actions';
import { OauthData, OAuthValidateResponse } from '../../services/api/oauth'; import { OAuthValidateResponse } from 'app/services/api/oauth';
const oauthData: OauthData = { import { OAuthState } from './reducer';
clientId: '',
redirectUrl: '', const oauthData: OAuthState = {
responseType: '', params: {
scope: '', clientId: '',
state: '', redirectUrl: '',
responseType: '',
scope: '',
state: '',
},
prompt: 'none', prompt: 'none',
}; };
@@ -64,9 +68,6 @@ describe('components/auth/actions', () => {
name: '', name: '',
description: '', description: '',
}, },
oAuth: {
state: 123,
},
session: { session: {
scopes: ['account_info'], scopes: ['account_info'],
}, },
@@ -86,7 +87,6 @@ describe('components/auth/actions', () => {
[setClient(resp.client)], [setClient(resp.client)],
[ [
setOAuthRequest({ setOAuthRequest({
...resp.oAuth,
prompt: 'none', prompt: 'none',
loginHint: undefined, loginHint: undefined,
}), }),

View File

@@ -13,7 +13,7 @@ import {
recoverPassword as recoverPasswordEndpoint, recoverPassword as recoverPasswordEndpoint,
OAuthResponse, OAuthResponse,
} from 'app/services/api/authentication'; } from 'app/services/api/authentication';
import oauth, { OauthData, Scope } from 'app/services/api/oauth'; import oauth, { OauthRequestData, Scope } from 'app/services/api/oauth';
import { import {
register as registerEndpoint, register as registerEndpoint,
activate as activateEndpoint, activate as activateEndpoint,
@@ -314,38 +314,17 @@ const KNOWN_SCOPES: ReadonlyArray<string> = [
'account_info', 'account_info',
'account_email', 'account_email',
]; ];
/**
* @param {object} oauthData export function oAuthValidate(oauthData: Pick<OAuthState, 'params' | 'description' | 'prompt' | 'loginHint'>) {
* @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) {
// TODO: move to oAuth actions? // 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) => return wrapInLoader((dispatch) =>
oauth oauth
.validate(oauthData) .validate(getOAuthRequest(oauthData))
.then((resp) => { .then((resp) => {
const { scopes } = resp.session; const { scopes } = resp.session;
const invalidScopes = scopes.filter((scope) => !KNOWN_SCOPES.includes(scope)); 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) { if (invalidScopes.length) {
logger.error('Got invalid scopes after oauth validation', { 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(setClient(resp.client));
dispatch( dispatch(
setOAuthRequest({ setOAuthRequest({
...resp.oAuth, params: oauthData.params,
prompt: oauthData.prompt || 'none', description: oauthData.description,
loginHint: oauthData.loginHint, loginHint: oauthData.loginHint,
prompt,
}), }),
); );
dispatch(setScopes(scopes)); 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 } = {}) { export function oAuthComplete(params: { accept?: boolean } = {}) {
return wrapInLoader( return wrapInLoader(
async ( async (
@@ -388,7 +368,7 @@ export function oAuthComplete(params: { accept?: boolean } = {}) {
getState, getState,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
redirectUri: string; redirectUri?: string;
}> => { }> => {
const oauthData = getState().auth.oauth; const oauthData = getState().auth.oauth;
@@ -397,11 +377,14 @@ export function oAuthComplete(params: { accept?: boolean } = {}) {
} }
try { try {
const resp = await oauth.complete(oauthData, params); const resp = await oauth.complete(getOAuthRequest(oauthData), params);
localStorage.removeItem('oauthData'); localStorage.removeItem('oauthData');
if (resp.redirectUri.startsWith('static_page')) { if (!resp.redirectUri) {
const displayCode = /static_page_with_code/.test(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=(.+)&/) || []; const [, code] = resp.redirectUri.match(/code=(.+)&/) || [];
[, resp.redirectUri] = resp.redirectUri.match(/^(.+)\?/) || []; [, 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( function handleOauthParamsValidation(
resp: { resp: {
[key: string]: any; [key: string]: any;
userMessage?: string; 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'); localStorage.removeItem('oauthData');
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
@@ -468,33 +479,16 @@ export type ClientAction = SetClientAction;
interface SetOauthAction extends ReduxAction { interface SetOauthAction extends ReduxAction {
type: 'set_oauth'; 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 // Input data is coming right from the query string, so the names
// are the same, as used for initializing OAuth2 request // are the same, as used for initializing OAuth2 request
export function setOAuthRequest(data: { // TODO: filter out allowed properties
client_id?: string; export function setOAuthRequest(payload: OAuthState | null): SetOauthAction {
redirect_uri?: string;
response_type?: string;
scope?: string;
prompt?: string;
loginHint?: string;
state?: string;
}): SetOauthAction {
return { return {
type: 'set_oauth', type: 'set_oauth',
payload: { 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 || '',
},
}; };
} }
@@ -503,9 +497,7 @@ interface SetOAuthResultAction extends ReduxAction {
payload: Pick<OAuthState, 'success' | 'code' | 'displayCode'>; payload: Pick<OAuthState, 'success' | 'code' | 'displayCode'>;
} }
export const SET_OAUTH_RESULT = 'set_oauth_result'; // TODO: remove export function setOAuthCode(payload: Pick<OAuthState, 'success' | 'code' | 'displayCode'>): SetOAuthResultAction {
export function setOAuthCode(payload: { success: boolean; code: string; displayCode: boolean }): SetOAuthResultAction {
return { return {
type: 'set_oauth_result', type: 'set_oauth_result',
payload, payload,
@@ -515,7 +507,7 @@ export function setOAuthCode(payload: { success: boolean; code: string; displayC
export function resetOAuth(): AppAction { export function resetOAuth(): AppAction {
return (dispatch): void => { return (dispatch): void => {
localStorage.removeItem('oauthData'); localStorage.removeItem('oauthData');
dispatch(setOAuthRequest({})); dispatch(setOAuthRequest(null));
}; };
} }

View File

@@ -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" />,
},
});

View File

@@ -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>
);
}
}

View File

@@ -0,0 +1 @@
export { default } from './DeviceCode';

View File

@@ -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 { FormattedMessage as Message } from 'react-intl';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { connect } from 'app/functions'; import { useReduxSelector } from 'app/functions';
import { Button } from 'app/components/ui/form'; import { Button } from 'app/components/ui/form';
import copy from 'app/services/copy'; import copy from 'app/services/copy';
@@ -16,102 +17,93 @@ interface Props {
success?: boolean; success?: boolean;
} }
class Finish extends React.Component<Props> { const Finish: FC<Props> = () => {
render() { const { client, oauth } = useReduxSelector((state) => state.auth);
const { appName, code, state, displayCode, success } = this.props;
const authData = JSON.stringify({ const onCopyClick: MouseEventHandler = (event) => {
auth_code: code, event.preventDefault();
state, 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 ( if (!client || !oauth) {
<div className={styles.finishPage}> return <Redirect to="/" />;
<Helmet title={authData} /> }
{success ? ( return (
<div> <div className={styles.finishPage}>
<div className={styles.successBackground} /> {authData && <Helmet title={authData} />}
<div className={styles.greenTitle}>
<Message {oauth.success ? (
key="authForAppSuccessful" <div>
defaultMessage="Authorization for {appName} was successfully completed" <div className={styles.successBackground} />
values={{ <div className={styles.greenTitle}>
appName: <span className={styles.appName}>{appName}</span>, <Message
}} key="authForAppSuccessful"
/> defaultMessage="Authorization for {appName} was successfully completed"
</div> values={{
{displayCode ? ( appName: <span className={styles.appName}>{client.name}</span>,
<div data-testid="oauth-code-container"> }}
<div className={styles.description}> />
<Message </div>
key="passCodeToApp" {oauth.displayCode ? (
defaultMessage="To complete authorization process, please, provide the following code to {appName}" <div data-testid="oauth-code-container">
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>
) : (
<div className={styles.description}> <div className={styles.description}>
<Message <Message
key="waitAppReaction" key="passCodeToApp"
defaultMessage="Please, wait till your application response" defaultMessage="To complete authorization process, please, provide the following code to {appName}"
values={{ appName: client.name }}
/> />
</div> </div>
)} <div className={styles.codeContainer}>
</div> <div className={styles.code}>{oauth.code}</div>
) : ( </div>
<div> <Button color="green" small onClick={onCopyClick}>
<div className={styles.failBackground} /> <Message key="copy" defaultMessage="Copy" />
<div className={styles.redTitle}> </Button>
<Message
key="authForAppFailed"
defaultMessage="Authorization for {appName} was failed"
values={{
appName: <span className={styles.appName}>{appName}</span>,
}}
/>
</div> </div>
) : (
<div className={styles.description}> <div className={styles.description}>
<Message <Message
key="waitAppReaction" key="waitAppReaction"
defaultMessage="Please, wait till your application response" defaultMessage="Please, wait till your application response"
/> />
</div> </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}>
</div> <Message key="waitAppReaction" defaultMessage="Please, wait till your application response" />
); </div>
} </div>
)}
</div>
);
};
onCopyClick: MouseEventHandler = (event) => { export default Finish;
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);

View File

@@ -0,0 +1 @@
export { default } from './Finish';

View File

@@ -38,15 +38,34 @@ export interface Client {
description: string; description: string;
} }
export interface OAuthState { export interface OauthAuthCodeFlowParams {
clientId: string; clientId: string;
redirectUrl: string; redirectUrl: string;
responseType: string; responseType: string;
description?: string;
scope: string;
prompt: string;
loginHint: string;
state: 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; success?: boolean;
code?: string; code?: string;
displayCode?: boolean; displayCode?: boolean;

View File

@@ -1,19 +1,18 @@
import React from 'react'; import React, { FC } from 'react';
import { Route, RouteProps } from 'react-router-dom'; import { Route, RouteProps } from 'react-router-dom';
import AuthFlowRouteContents from './AuthFlowRouteContents'; import AuthFlowRouteContents from './AuthFlowRouteContents';
export default function AuthFlowRoute(props: RouteProps) { // Make "component" prop required
const { component: Component, ...routeProps } = props; type Props = Omit<RouteProps, 'component'> & Required<Pick<RouteProps, 'component'>>;
if (!Component) {
throw new Error('props.component required');
}
const AuthFlowRoute: FC<Props> = ({ component: Component, ...props }) => {
return ( return (
<Route <Route
{...routeProps} {...props}
render={(routerProps) => <AuthFlowRouteContents routerProps={routerProps} component={Component} />} render={(routerProps) => <AuthFlowRouteContents routerProps={routerProps} component={Component} />}
/> />
); );
} };
export default AuthFlowRoute;

View File

@@ -4,7 +4,7 @@ import { Redirect, RouteComponentProps } from 'react-router-dom';
import authFlow from 'app/services/authFlow'; import authFlow from 'app/services/authFlow';
interface Props { interface Props {
component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>; component: React.ComponentType<RouteComponentProps> | React.ComponentType<any>;
routerProps: RouteComponentProps; 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) { if (!this.mounted) {
return; return;
} }

View File

@@ -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 { Route, Switch, Redirect, RouteComponentProps } from 'react-router-dom';
import AppInfo from 'app/components/auth/appInfo/AppInfo'; 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 Login from 'app/components/auth/login/Login';
import Permissions from 'app/components/auth/permissions/Permissions'; import Permissions from 'app/components/auth/permissions/Permissions';
import ChooseAccount from 'app/components/auth/chooseAccount/ChooseAccount'; import ChooseAccount from 'app/components/auth/chooseAccount/ChooseAccount';
import DeviceCode from 'app/components/auth/deviceCode';
import Activation from 'app/components/auth/activation/Activation'; import Activation from 'app/components/auth/activation/Activation';
import ResendActivation from 'app/components/auth/resendActivation/ResendActivation'; import ResendActivation from 'app/components/auth/resendActivation/ResendActivation';
import Password from 'app/components/auth/password/Password'; 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 ForgotPassword from 'app/components/auth/forgotPassword/ForgotPassword';
import RecoverPassword from 'app/components/auth/recoverPassword/RecoverPassword'; import RecoverPassword from 'app/components/auth/recoverPassword/RecoverPassword';
import Mfa from 'app/components/auth/mfa/Mfa'; 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 { useReduxSelector } from 'app/functions';
import { Factory } from 'app/components/auth/factory'; import { Factory } from 'app/components/auth/factory';
@@ -27,7 +28,7 @@ import styles from './auth.scss';
// so that it persist disregarding remounts // so that it persist disregarding remounts
let isSidebarHiddenCache = false; let isSidebarHiddenCache = false;
const AuthPage: ComponentType = () => { const AuthPage: FC = () => {
const [isSidebarHidden, setIsSidebarHidden] = useState<boolean>(isSidebarHiddenCache); const [isSidebarHidden, setIsSidebarHidden] = useState<boolean>(isSidebarHiddenCache);
const client = useReduxSelector((state) => state.auth.client); const client = useReduxSelector((state) => state.auth.client);
@@ -54,6 +55,7 @@ const AuthPage: ComponentType = () => {
<Route path="/choose-account" render={renderPanelTransition(ChooseAccount)} /> <Route path="/choose-account" render={renderPanelTransition(ChooseAccount)} />
<Route path="/oauth/choose-account" render={renderPanelTransition(ChooseAccount)} /> <Route path="/oauth/choose-account" render={renderPanelTransition(ChooseAccount)} />
<Route path="/oauth/finish" component={Finish} /> <Route path="/oauth/finish" component={Finish} />
<Route path="/code" component={renderPanelTransition(DeviceCode)} />
<Route path="/accept-rules" render={renderPanelTransition(AcceptRules)} /> <Route path="/accept-rules" render={renderPanelTransition(AcceptRules)} />
<Route path="/forgot-password" render={renderPanelTransition(ForgotPassword)} /> <Route path="/forgot-password" render={renderPanelTransition(ForgotPassword)} />
<Route path="/recover-password/:key?" render={renderPanelTransition(RecoverPassword)} /> <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(); const { Title, Body, Footer, Links } = factory();
return (props) => ( return (props) => (

View File

@@ -24,35 +24,27 @@ export interface OauthAppResponse {
minecraftServerIp?: string; minecraftServerIp?: string;
} }
interface OauthRequestData { interface AuthCodeFlowRequestData {
client_id: string; client_id: string;
redirect_uri: string; redirect_uri: string;
response_type: string; response_type: string;
description?: string;
scope: string; scope: string;
prompt: string;
login_hint?: string;
state?: string; state?: string;
} }
export interface OauthData { interface DeviceCodeFlowRequestData {
clientId: string; user_code: 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;
} }
export type OauthRequestData = (AuthCodeFlowRequestData | DeviceCodeFlowRequestData) & {
description?: string;
};
export interface OAuthValidateResponse { export interface OAuthValidateResponse {
session: { session: {
scopes: Scope[]; scopes: Scope[];
}; };
client: Client; client: Client;
oAuth: {}; // TODO: improve typing
} }
interface FormPayloads { interface FormPayloads {
@@ -64,20 +56,20 @@ interface FormPayloads {
} }
const api = { const api = {
validate(oauthData: OauthData) { validate(oauthData: OauthRequestData) {
return request return request
.get<OAuthValidateResponse>('/api/oauth2/v1/validate', getOAuthRequest(oauthData)) .get<OAuthValidateResponse>('/api/oauth2/v1/validate', oauthData)
.catch(handleOauthParamsValidation); .catch(handleOauthParamsValidation);
}, },
complete( complete(
oauthData: OauthData, oauthData: OauthRequestData,
params: { accept?: boolean } = {}, params: { accept?: boolean } = {},
): Promise<{ ): Promise<{
success: boolean; success: boolean;
redirectUri: string; redirectUri?: string;
}> { }> {
const query = request.buildQuery(getOAuthRequest(oauthData)); const query = request.buildQuery(oauthData);
return request return request
.post<{ .post<{
@@ -146,37 +138,18 @@ if ('Cypress' in window) {
export default api; 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( function handleOauthParamsValidation(
resp: resp:
| { [key: string]: any } | Record<string, any>
| { | {
statusCode: number; statusCode: number;
success: false; 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; parameter: string;
} = {}, } = {},
) { ) {

View File

@@ -5,7 +5,7 @@ import AuthFlow from 'app/services/authFlow/AuthFlow';
import AbstractState from 'app/services/authFlow/AbstractState'; import AbstractState from 'app/services/authFlow/AbstractState';
import localStorage from 'app/services/localStorage'; 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 RegisterState from 'app/services/authFlow/RegisterState';
import RecoverPasswordState from 'app/services/authFlow/RecoverPasswordState'; import RecoverPasswordState from 'app/services/authFlow/RecoverPasswordState';
import ForgotPasswordState from 'app/services/authFlow/ForgotPasswordState'; import ForgotPasswordState from 'app/services/authFlow/ForgotPasswordState';
@@ -314,9 +314,9 @@ describe('AuthFlow', () => {
'/oauth/permissions': LoginState, '/oauth/permissions': LoginState,
'/oauth/choose-account': LoginState, '/oauth/choose-account': LoginState,
'/oauth/finish': LoginState, '/oauth/finish': LoginState,
'/oauth2/v1/foo': OAuthState, '/oauth2/v1/foo': InitOAuthAuthCodeFlowState,
'/oauth2/v1': OAuthState, '/oauth2/v1': InitOAuthAuthCodeFlowState,
'/oauth2': OAuthState, '/oauth2': InitOAuthAuthCodeFlowState,
'/register': RegisterState, '/register': RegisterState,
'/choose-account': ChooseAccountState, '/choose-account': ChooseAccountState,
'/recover-password': RecoverPasswordState, '/recover-password': RecoverPasswordState,
@@ -379,7 +379,7 @@ describe('AuthFlow', () => {
flow.handleRequest({ path, query: new URLSearchParams({}), params: {} }, () => {}, callback); flow.handleRequest({ path, query: new URLSearchParams({}), params: {} }, () => {}, callback);
expect(flow.setState, 'was called once'); 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'); expect(callback, 'was called twice');
}); });

View File

@@ -10,10 +10,12 @@ import {
} from 'app/components/accounts/actions'; } from 'app/components/accounts/actions';
import * as actions from 'app/components/auth/actions'; import * as actions from 'app/components/auth/actions';
import { updateUser } from 'app/components/user/actions'; import { updateUser } from 'app/components/user/actions';
import FinishState from './FinishState';
import RegisterState from './RegisterState'; import RegisterState from './RegisterState';
import LoginState from './LoginState'; import LoginState from './LoginState';
import OAuthState from './OAuthState'; import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState';
import InitOAuthDeviceCodeFlowState from './InitOAuthDeviceCodeFlowState';
import ForgotPasswordState from './ForgotPasswordState'; import ForgotPasswordState from './ForgotPasswordState';
import RecoverPasswordState from './RecoverPasswordState'; import RecoverPasswordState from './RecoverPasswordState';
import ActivationState from './ActivationState'; import ActivationState from './ActivationState';
@@ -22,11 +24,11 @@ import ChooseAccountState from './ChooseAccountState';
import ResendActivationState from './ResendActivationState'; import ResendActivationState from './ResendActivationState';
import State from './State'; import State from './State';
type Request = { interface Request {
path: string; path: string;
query: URLSearchParams; query: URLSearchParams;
params: Record<string, any>; params: Record<string, any>;
}; }
export const availableActions = { export const availableActions = {
updateUser, updateUser,
@@ -104,7 +106,11 @@ export default class AuthFlow implements AuthContext {
this.replace(route); this.replace(route);
} }
browserHistory[options.replace ? 'replace' : 'push'](route); if (options.replace) {
browserHistory.replace(route);
} else {
browserHistory.push(route);
}
} }
this.replace = null; this.replace = null;
@@ -132,11 +138,7 @@ export default class AuthFlow implements AuthContext {
} }
setState(state: State) { setState(state: State) {
if (!state) { this.state?.leave(this);
throw new Error('State is required');
}
this.state && this.state.leave(this);
this.prevState = this.state; this.prevState = this.state;
this.state = state; this.state = state;
const resp = this.state.enter(this); 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 * 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; const { path } = request;
this.replace = replace; this.replace = replace;
this.onReady = callback; this.onReady = callback;
@@ -218,13 +212,20 @@ export default class AuthFlow implements AuthContext {
this.setState(new ChooseAccountState()); this.setState(new ChooseAccountState());
break; break;
case '/code':
this.setState(new InitOAuthDeviceCodeFlowState());
break;
case '/oauth/finish':
this.setState(new FinishState());
break;
case '/': case '/':
case '/login': case '/login':
case '/password': case '/password':
case '/mfa': case '/mfa':
case '/accept-rules': case '/accept-rules':
case '/oauth/permissions': case '/oauth/permissions':
case '/oauth/finish':
case '/oauth/choose-account': case '/oauth/choose-account':
this.setState(new LoginState()); this.setState(new LoginState());
break; break;
@@ -234,7 +235,7 @@ export default class AuthFlow implements AuthContext {
path.replace(/(.)\/.+/, '$1') // use only first part of an url path.replace(/(.)\/.+/, '$1') // use only first part of an url
) { ) {
case '/oauth2': case '/oauth2':
this.setState(new OAuthState()); this.setState(new InitOAuthAuthCodeFlowState());
break; break;
case '/activation': case '/activation':
this.setState(new ActivationState()); this.setState(new ActivationState());

View File

@@ -1,4 +1,6 @@
import { OAuthState } from 'app/components/auth/reducer';
import { getActiveAccount } from 'app/components/accounts/reducer'; import { getActiveAccount } from 'app/components/accounts/reducer';
import AbstractState from './AbstractState'; import AbstractState from './AbstractState';
import LoginState from './LoginState'; import LoginState from './LoginState';
import PermissionsState from './PermissionsState'; import PermissionsState from './PermissionsState';
@@ -11,8 +13,16 @@ import { AuthContext } from './AuthFlow';
const PROMPT_ACCOUNT_CHOOSE = 'select_account'; const PROMPT_ACCOUNT_CHOOSE = 'select_account';
const PROMPT_PERMISSIONS = 'consent'; 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 { export default class CompleteState extends AbstractState {
isPermissionsAccepted: boolean | void; private readonly isPermissionsAccepted?: boolean;
constructor( constructor(
options: { options: {
@@ -36,7 +46,7 @@ export default class CompleteState extends AbstractState {
context.setState(new LoginState()); context.setState(new LoginState());
} else if (user.shouldAcceptRules && !user.isDeleted) { } else if (user.shouldAcceptRules && !user.isDeleted) {
context.setState(new AcceptRulesState()); context.setState(new AcceptRulesState());
} else if (oauth && oauth.clientId) { } else if (oauth?.params) {
return this.processOAuth(context); return this.processOAuth(context);
} else { } else {
context.navigate('/'); context.navigate('/');
@@ -44,6 +54,8 @@ export default class CompleteState extends AbstractState {
} }
processOAuth(context: AuthContext): Promise<void> | void { processOAuth(context: AuthContext): Promise<void> | void {
console.log('process oauth', this.isPermissionsAccepted);
const { auth, accounts, user } = context.getState(); const { auth, accounts, user } = context.getState();
let { isSwitcherEnabled } = auth; let { isSwitcherEnabled } = auth;
@@ -80,7 +92,7 @@ export default class CompleteState extends AbstractState {
// so that they can see, that their account was deleted // so that they can see, that their account was deleted
// (this info is displayed on switcher) // (this info is displayed on switcher)
user.isDeleted || user.isDeleted ||
oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE)) hasPrompt(oauth.prompt, PROMPT_ACCOUNT_CHOOSE))
) { ) {
context.setState(new ChooseAccountState()); context.setState(new ChooseAccountState());
} else if (user.isDeleted) { } else if (user.isDeleted) {
@@ -92,35 +104,35 @@ export default class CompleteState extends AbstractState {
} else if (oauth.code) { } else if (oauth.code) {
context.setState(new FinishState()); context.setState(new FinishState());
} else { } else {
const data: { [key: string]: any } = {}; const data: Record<string, any> = {};
if (typeof this.isPermissionsAccepted !== 'undefined') { if (typeof this.isPermissionsAccepted !== 'undefined') {
data.accept = this.isPermissionsAccepted; 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()); context.setState(new PermissionsState());
return; return;
} }
// TODO: it seems that oAuthComplete may be a separate state // TODO: it seems that oAuthComplete may be a separate state
return context.run('oAuthComplete', data).then( return context
(resp: { redirectUri: string }) => { .run('oAuthComplete', data)
.then((resp: { redirectUri?: string }) => {
// TODO: пусть в стейт попадает флаг или тип авторизации // TODO: пусть в стейт попадает флаг или тип авторизации
// вместо волшебства над редирект урлой // вместо волшебства над редирект урлой
if (resp.redirectUri.includes('static_page')) { if (!resp.redirectUri || resp.redirectUri.includes('static_page')) {
context.setState(new FinishState()); context.setState(new FinishState());
} else { } else {
return context.run('redirect', resp.redirectUri); return context.run('redirect', resp.redirectUri);
} }
}, })
(resp) => { .catch((resp) => {
if (resp.unauthorized) { if (resp.unauthorized) {
context.setState(new LoginState()); context.setState(new LoginState());
} else if (resp.acceptRequired) { } else if (resp.acceptRequired) {
context.setState(new PermissionsState()); context.setState(new PermissionsState());
} }
}, });
);
} }
} }
} }

View File

@@ -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;
});
}
}

View File

@@ -1,17 +1,17 @@
import sinon, { SinonMock } from 'sinon'; import sinon, { SinonMock } from 'sinon';
import OAuthState from 'app/services/authFlow/OAuthState'; import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState';
import CompleteState from 'app/services/authFlow/CompleteState'; import CompleteState from 'app/services/authFlow/CompleteState';
import { bootstrap, expectState, expectRun, MockedAuthContext } from './helpers'; import { bootstrap, expectState, expectRun, MockedAuthContext } from './helpers';
describe('OAuthState', () => { describe('OAuthState', () => {
let state: OAuthState; let state: InitOAuthAuthCodeFlowState;
let context: MockedAuthContext; let context: MockedAuthContext;
let mock: SinonMock; let mock: SinonMock;
beforeEach(() => { beforeEach(() => {
state = new OAuthState(); state = new InitOAuthAuthCodeFlowState();
const data = bootstrap(); const data = bootstrap();
context = data.context; context = data.context;

View File

@@ -2,20 +2,22 @@ import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow'; import { AuthContext } from './AuthFlow';
import CompleteState from './CompleteState'; import CompleteState from './CompleteState';
export default class OAuthState extends AbstractState { export default class InitOAuthAuthCodeFlowState extends AbstractState {
enter(context: AuthContext): Promise<void> | void { enter(context: AuthContext): Promise<void> | void {
const { query, params } = context.getRequest(); const { query, params } = context.getRequest();
return context return context
.run('oAuthValidate', { .run('oAuthValidate', {
clientId: query.get('client_id') || params.clientId, params: {
redirectUrl: query.get('redirect_uri')!, clientId: query.get('client_id') || params.clientId,
responseType: query.get('response_type')!, redirectUrl: query.get('redirect_uri')!,
responseType: query.get('response_type')!,
scope: (query.get('scope') || '').replace(/,/g, ' '),
state: query.get('state')!,
},
description: query.get('description')!, description: query.get('description')!,
scope: (query.get('scope') || '').replace(/,/g, ' '),
prompt: query.get('prompt')!, prompt: query.get('prompt')!,
loginHint: query.get('login_hint')!, loginHint: query.get('login_hint')!,
state: query.get('state')!,
}) })
.then(() => context.setState(new CompleteState())); .then(() => context.setState(new CompleteState()));
} }

View File

@@ -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());
}
}

View File

@@ -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_required': () => <Message key="redirectUriRequired" defaultMessage="Redirect URI is required" />,
'error.redirectUri_invalid': () => <Message key="redirectUriInvalid" defaultMessage="Redirect URI is invalid" />, 'error.redirectUri_invalid': () => <Message key="redirectUriInvalid" defaultMessage="Redirect URI is invalid" />,
'invalid_user_code': () => <Message key="invalidUserCode" defaultMessage="Invalid Device Code" />,
}; };
interface ErrorLiteral { interface ErrorLiteral {