Initial device code flow implementation

This commit is contained in:
ErickSkrauch 2024-12-10 20:42:06 +01:00
parent 533849026d
commit 3f0565e26b
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
20 changed files with 370 additions and 272 deletions

View File

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

View File

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

View File

@ -0,0 +1,18 @@
import React from 'react';
import { FormattedMessage as Message, defineMessages } from 'react-intl';
import factory from '../factory';
import Body from './DeviceCodeBody';
const messages = defineMessages({
deviceCodeTitle: 'Device code',
});
export default factory({
title: messages.deviceCodeTitle,
body: Body,
footer: {
color: 'green',
children: <Message key="continueButton" defaultMessage="Continue" />,
},
});

View File

@ -0,0 +1,31 @@
import React from 'react';
import { defineMessages } from 'react-intl';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
const messages = defineMessages({
deviceCode: 'Device code',
});
export default class DeviceCodeBody extends BaseAuthBody {
static displayName = 'DeviceCodeBody';
static panelId = 'deviceCode';
autoFocusField = 'user_code';
render() {
return (
<div>
{this.renderErrors()}
<Input
{...this.bindField('user_code')}
icon="key"
required
placeholder={messages.deviceCode}
/>
</div>
);
}
}

View File

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

View File

@ -1,8 +1,9 @@
import React, { MouseEventHandler } from 'react';
import React, { FC, MouseEventHandler, useEffect } from 'react';
import { Redirect } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import { 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;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { ComponentType, ReactNode, useCallback, useState } from 'react';
import React, { FC, useCallback, useState } from 'react';
import { Route, Switch, Redirect, RouteComponentProps } from 'react-router-dom';
import 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) => (

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import CompleteState from './CompleteState';
export default class DeviceCodeState extends AbstractState {
async resolve(context: AuthContext, payload: { user_code: string }): Promise<void> {
const { query } = context.getRequest();
context
.run('oAuthValidate', {
params: {
userCode: payload.user_code,
},
description: query.get('description')!,
prompt: query.get('prompt')!,
})
.then(() => context.setState(new CompleteState()))
.catch((err) => {
if (err.error === 'invalid_user_code') {
return context.run('setErrors', { [err.parameter]: err.error });
}
throw err;
});
}
}

View File

@ -1,17 +1,17 @@
import sinon, { SinonMock } from 'sinon';
import 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;

View File

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

View File

@ -0,0 +1,27 @@
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import CompleteState from './CompleteState';
import DeviceCodeState from './DeviceCodeState';
export default class InitOAuthDeviceCodeFlowState extends AbstractState {
async enter(context: AuthContext): Promise<void> {
const { query } = context.getRequest();
const userCode = query.get('user_code');
if (userCode) {
try {
await context.run('oAuthValidate', {
params: { userCode },
description: query.get('description')!,
prompt: query.get('prompt')!,
});
await context.setState(new CompleteState());
} catch {
// Ok, fallback to the default
}
}
return context.setState(new DeviceCodeState());
}
}

View File

@ -127,6 +127,8 @@ const errorsMap: Record<string, (props?: Record<string, any>) => ReactElement> =
'error.redirectUri_required': () => <Message key="redirectUriRequired" defaultMessage="Redirect URI is required" />,
'error.redirectUri_invalid': () => <Message key="redirectUriInvalid" defaultMessage="Redirect URI is invalid" />,
'invalid_user_code': () => <Message key="invalidUserCode" defaultMessage="Invalid Device Code" />,
};
interface ErrorLiteral {