mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
Initial device code flow implementation
This commit is contained in:
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
@@ -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));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 { 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);
|
|
||||||
|
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;
|
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;
|
||||||
|
@@ -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;
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -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) => (
|
||||||
|
@@ -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;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
|
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -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());
|
||||||
|
@@ -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());
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 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;
|
@@ -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()));
|
||||||
}
|
}
|
@@ -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_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 {
|
||||||
|
Reference in New Issue
Block a user