mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-01-14 07:42:06 +05:30
Initial device code flow implementation
This commit is contained in:
parent
533849026d
commit
3f0565e26b
@ -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,
|
||||
}),
|
||||
|
@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
18
packages/app/components/auth/deviceCode/DeviceCode.tsx
Normal file
18
packages/app/components/auth/deviceCode/DeviceCode.tsx
Normal 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" />,
|
||||
},
|
||||
});
|
31
packages/app/components/auth/deviceCode/DeviceCodeBody.tsx
Normal file
31
packages/app/components/auth/deviceCode/DeviceCodeBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
1
packages/app/components/auth/deviceCode/index.ts
Normal file
1
packages/app/components/auth/deviceCode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './DeviceCode';
|
@ -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;
|
||||
|
1
packages/app/components/auth/finish/index.ts
Normal file
1
packages/app/components/auth/finish/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Finish';
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) => (
|
||||
|
@ -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;
|
||||
} = {},
|
||||
) {
|
||||
|
@ -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');
|
||||
});
|
||||
|
||||
|
@ -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());
|
||||
|
@ -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());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
26
packages/app/services/authFlow/DeviceCodeState.ts
Normal file
26
packages/app/services/authFlow/DeviceCodeState.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
@ -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()));
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user