Initial device code flow implementation

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

View File

@@ -16,14 +16,18 @@ import {
login,
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;