OAuth through OS browser, added google login & open id methods (#5075)

* Initialised SSO with linking

* Fixed lint errors

* Added translation and version check

* Added translation & detox file: WIP

* Added google logo and login button

* Added openid login option

* Added tests

* Added fallback support for google

* Added server check for openid

* Removed unwated memos

* Server support changed to 5.32

* Added try catch for Linking openurl

* Removed 'or'

* Changed test case version to 5.32

* Changed server version to 5.32

* Update assets/base/i18n/en.json

Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com>

* Update assets/base/i18n/en.json

Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com>

* Update assets/base/i18n/en.json

Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com>

* Update app/screens/sso/sso_with_redirect_url.tsx

Co-authored-by: Hossein <hahmadia@users.noreply.github.com>

* Update assets/base/i18n/en.json

Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com>

* Added new custom url scheme for auth

* Added custom url scheme based on app build type, custom user agent for only google & misc typo fixes

* Added check for Deviceinfo

* Reverted to openLink

* Using tryOpenURL instead of Linking.openURL

* Changed office365 url

* Handling cookie read error caused on localhost

* Fixed lint errors

* Updated version in tests

Co-authored-by: Anurag Shivarathri <anurag@13.local>
Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com>
Co-authored-by: Hossein <hahmadia@users.noreply.github.com>
This commit is contained in:
Anurag Shivarathri 2021-01-19 21:15:24 +05:30 committed by GitHub
parent 5197332d02
commit d11d613815
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 851 additions and 342 deletions

View file

@ -43,6 +43,12 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mattermost" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mmauthbeta" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
<service android:name=".NotificationDismissService"

View file

@ -77,7 +77,9 @@ const ViewTypes = keyMirror({
REMOVE_LAST_CHANNEL_FOR_TEAM: null,
GITLAB: null,
GOOGLE: null,
OFFICE365: null,
OPENID: null,
SAML: null,
SET_INITIAL_POST_VISIBILITY: null,

View file

@ -82,6 +82,7 @@ export type Config = {
EnableSignUpWithGitLab: string;
EnableSignUpWithGoogle: string;
EnableSignUpWithOffice365: string;
EnableSignUpWithOpenId: string;
EnableSVGs: string;
EnableTesting: string;
EnableThemeSelection: string;
@ -128,6 +129,8 @@ export type Config = {
MaxFileSize: string;
MaxNotificationsPerChannel: string;
MinimumHashtagLength: string;
OpenIdButtonColor: string;
OpenIdButtonText: string;
PasswordMinimumLength: string;
PasswordRequireLowercase: string;
PasswordRequireNumber: string;

View file

@ -5,3 +5,4 @@ declare module 'remote-redux-devtools';
declare module 'redux-action-buffer';
declare module 'react-intl';
declare module 'react-native-local-auth';
declare module 'react-native-cookies';

View file

@ -17,10 +17,12 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import {goToScreen} from '@actions/navigation';
import LocalConfig from '@assets/config';
import gitlab from '@assets/images/gitlab.png';
import google from '@assets/images/google.png';
import FormattedText from '@components/formatted_text';
import StatusBar from '@components/status_bar';
import {ViewTypes} from '@constants';
import globalEventHandler from '@init/global_event_handler';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {preventDoubleTap} from '@utils/tap';
import {GlobalStyles} from 'app/styles';
@ -105,14 +107,14 @@ export default class LoginOptions extends PureComponent {
if (!forceHideFromLocal && license.IsLicensed === 'true' && config.EnableLdap === 'true') {
const backgroundColor = config.LDAPLoginButtonColor || '#2389d7';
const additionalStyle = {
const additionalButtonStyle = {
backgroundColor,
borderColor: 'transparent',
borderWidth: 0,
};
if (config.LDAPLoginButtonBorderColor) {
additionalStyle.borderColor = config.LDAPLoginButtonBorderColor;
additionalButtonStyle.borderColor = config.LDAPLoginButtonBorderColor;
}
const textColor = config.LDAPLoginButtonTextColor || 'white';
@ -138,7 +140,7 @@ export default class LoginOptions extends PureComponent {
<Button
key='ldap'
onPress={this.goToLogin}
containerStyle={[GlobalStyles.signupButton, additionalStyle]}
containerStyle={[GlobalStyles.signupButton, additionalButtonStyle]}
>
{buttonText}
</Button>
@ -154,18 +156,31 @@ export default class LoginOptions extends PureComponent {
const forceHideFromLocal = LocalConfig.HideGitLabLoginExperimental;
if (!forceHideFromLocal && config.EnableSignUpWithGitLab === 'true') {
const additionalButtonStyle = {
backgroundColor: '#548',
borderColor: 'transparent',
borderWidth: 0,
};
const logoStyle = {
height: 18,
marginRight: 5,
width: 18,
};
const textColor = 'white';
return (
<Button
key='gitlab'
onPress={preventDoubleTap(() => this.goToSSO(ViewTypes.GITLAB))}
containerStyle={[GlobalStyles.signupButton, {backgroundColor: '#548', borderColor: 'transparent', borderWidth: 0}]}
containerStyle={[GlobalStyles.signupButton, additionalButtonStyle]}
>
<Image
source={gitlab}
style={{height: 18, marginRight: 5, width: 18}}
style={logoStyle}
/>
<Text
style={[GlobalStyles.signupButtonText, {color: 'white'}]}
style={[GlobalStyles.signupButtonText, {color: textColor}]}
>
{'GitLab'}
</Text>
@ -176,15 +191,53 @@ export default class LoginOptions extends PureComponent {
return null;
};
renderGoogleOption = () => {
const {config} = this.props;
if (config.EnableSignUpWithGoogle === 'true') {
const additionalButtonStyle = {
backgroundColor: '#c23321',
borderColor: 'transparent',
borderWidth: 0,
};
const logoStyle = {
height: 18,
marginRight: 5,
width: 18,
};
const textColor = 'white';
return (
<Button
key='google'
onPress={preventDoubleTap(() => this.goToSSO(ViewTypes.GOOGLE))}
containerStyle={[GlobalStyles.signupButton, additionalButtonStyle]}
>
<Image
source={google}
style={logoStyle}
/>
<FormattedText
id='signup.google'
defaultMessage='Google Apps'
style={[GlobalStyles.signupButtonText, {color: textColor}]}
/>
</Button>
);
}
return null;
};
renderO365Option = () => {
const {config, license} = this.props;
const forceHideFromLocal = LocalConfig.HideO365LoginExperimental;
const o365Enabled = config.EnableSignUpWithOffice365 === 'true' && license.IsLicensed === 'true' && license.Office365OAuth === 'true';
if (!forceHideFromLocal && o365Enabled) {
const backgroundColor = '#2389d7';
const additionalStyle = {
backgroundColor,
const additionalButtonStyle = {
backgroundColor: '#2389d7',
borderColor: 'transparent',
borderWidth: 0,
};
@ -195,7 +248,7 @@ export default class LoginOptions extends PureComponent {
<Button
key='o365'
onPress={preventDoubleTap(() => this.goToSSO(ViewTypes.OFFICE365))}
containerStyle={[GlobalStyles.signupButton, additionalStyle]}
containerStyle={[GlobalStyles.signupButton, additionalButtonStyle]}
>
<FormattedText
id='signup.office365'
@ -209,6 +262,37 @@ export default class LoginOptions extends PureComponent {
return null;
};
renderOpenIdOption = () => {
const {config, license} = this.props;
const openIdEnabled = config.EnableSignUpWithOpenId === 'true' && license.IsLicensed === 'true' && isMinimumServerVersion(config.Version, 5, 33, 0);
if (openIdEnabled) {
const additionalButtonStyle = {
backgroundColor: config.OpenIdButtonColor || '#145DBF',
borderColor: 'transparent',
borderWidth: 0,
};
const textColor = 'white';
return (
<Button
key='openId'
onPress={preventDoubleTap(() => this.goToSSO(ViewTypes.OPENID))}
containerStyle={[GlobalStyles.signupButton, additionalButtonStyle]}
>
<FormattedText
id='signup.openid'
defaultMessage={config.OpenIdButtonText || 'OpenID'}
style={[GlobalStyles.signupButtonText, {color: textColor}]}
/>
</Button>
);
}
return null;
};
renderSamlOption = () => {
const {config, license} = this.props;
const forceHideFromLocal = LocalConfig.HideSAMLLoginExperimental;
@ -279,8 +363,10 @@ export default class LoginOptions extends PureComponent {
{this.renderEmailOption()}
{this.renderLdapOption()}
{this.renderGitlabOption()}
{this.renderGoogleOption()}
{this.renderSamlOption()}
{this.renderO365Option()}
{this.renderOpenIdOption()}
</ScrollView>
</SafeAreaView>
);

View file

@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import FormattedText from 'app/components/formatted_text';
import {shallowWithIntl} from 'test/intl-test-helper';
import LoginOptions from './login_options';
describe('Login options', () => {
const baseProps = {
config: {
Version: '5.32.0',
},
license: {
IsLicensed: 'true',
},
};
test('should show google signin button only when enabled', () => {
const basicWrapper = shallowWithIntl(<LoginOptions {...baseProps}/>);
expect(basicWrapper.find(FormattedText).find({id: 'signup.google'}).exists()).toBe(false);
const props = {
...baseProps,
config: {
...baseProps.config,
EnableSignUpWithGoogle: 'true',
},
};
const configuredWrapper = shallowWithIntl(<LoginOptions {...props}/>);
expect(configuredWrapper.find(FormattedText).find({id: 'signup.google'}).exists()).toBe(true);
});
test('should show open id button only when enabled and from version 5.33', () => {
const basicWrapper = shallowWithIntl(<LoginOptions {...baseProps}/>);
expect(basicWrapper.find(FormattedText).find({id: 'signup.openid'}).exists()).toBe(false);
const newVersionProps = {
...baseProps,
config: {
...baseProps.config,
EnableSignUpWithOpenId: 'true',
Version: '5.33.0',
},
};
const newVersionWrapper = shallowWithIntl(<LoginOptions {...newVersionProps}/>);
expect(newVersionWrapper.find(FormattedText).find({id: 'signup.openid'}).exists()).toBe(true);
const oldVersionProps = {
...baseProps,
config: {
...baseProps.config,
EnableSignUpWithOpenId: 'true',
Version: '5.30.0',
},
};
const oldVersionWrapper = shallowWithIntl(<LoginOptions {...oldVersionProps}/>);
expect(oldVersionWrapper.find(FormattedText).find({id: 'signup.openid'}).exists()).toBe(false);
});
});

View file

@ -34,6 +34,7 @@ import FormattedText from '@components/formatted_text';
import fetchConfig from '@init/fetch';
import globalEventHandler from '@init/global_event_handler';
import {Client4} from '@mm-redux/client';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {checkUpgradeType, isUpgradeAvailable} from '@utils/client_upgrade';
import {t} from '@utils/i18n';
import {preventDoubleTap} from '@utils/tap';
@ -255,10 +256,12 @@ export default class SelectServer extends PureComponent {
const {config, license} = props;
const samlEnabled = config.EnableSaml === 'true' && license.IsLicensed === 'true' && license.SAML === 'true';
const gitlabEnabled = config.EnableSignUpWithGitLab === 'true';
const googleEnabled = config.EnableSignUpWithGoogle === 'true' && license.IsLicensed === 'true';
const o365Enabled = config.EnableSignUpWithOffice365 === 'true' && license.IsLicensed === 'true' && license.Office365OAuth === 'true';
const openIdEnabled = config.EnableSignUpWithOpenId === 'true' && license.IsLicensed === 'true' && isMinimumServerVersion(config.Version, 5, 33, 0);
let options = 0;
if (samlEnabled || gitlabEnabled || o365Enabled) {
if (samlEnabled || gitlabEnabled || googleEnabled || o365Enabled || openIdEnabled) {
options += 1;
}

View file

@ -1,29 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {ssoLogin} from '@actions/views/user';
import {scheduleExpiredNotification} from '@actions/views/session';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import SSO from './sso';
function mapStateToProps(state) {
return {
...state.views.selectServer,
theme: getTheme(state),
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
scheduleExpiredNotification,
ssoLogin,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(SSO);

130
app/screens/sso/index.tsx Normal file
View file

@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {shallowEqual, useDispatch, useSelector} from 'react-redux';
import type {GlobalState} from '@mm-redux/types/store';
import {resetToChannel} from 'app/actions/navigation';
import {ViewTypes} from 'app/constants';
import tracker from 'app/utils/time_tracker';
import {scheduleExpiredNotification} from '@actions/views/session';
import {ssoLogin} from '@actions/views/user';
import {DispatchFunc} from '@mm-redux/types/actions';
import {Client4} from '@mm-redux/client';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {ErrorApi} from '@mm-redux/types/client4';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import SSOWithRedirectURL from './sso_with_redirect_url';
import SSOWithWebView from './sso_with_webview';
interface SSOProps {
intl: typeof intlShape;
ssoType: string;
}
function SSO({intl, ssoType}: SSOProps) {
const [config, serverUrl, theme] = useSelector((state: GlobalState) => ([
getConfig(state),
state.views.selectServer.serverUrl,
getTheme(state),
]), shallowEqual);
const [loginError, setLoginError] = React.useState<string>('');
const asyncDispatch: DispatchFunc = useDispatch();
const dispatch = useDispatch();
let completeUrlPath = '';
let loginUrl = '';
switch (ssoType) {
case ViewTypes.GOOGLE: {
completeUrlPath = '/signup/google/complete';
loginUrl = `${serverUrl}/oauth/google/mobile_login`;
break;
}
case ViewTypes.GITLAB: {
completeUrlPath = '/signup/gitlab/complete';
loginUrl = `${serverUrl}/oauth/gitlab/mobile_login`;
break;
}
case ViewTypes.SAML: {
completeUrlPath = '/login/sso/saml';
loginUrl = `${serverUrl}/login/sso/saml?action=mobile`;
break;
}
case ViewTypes.OFFICE365: {
completeUrlPath = '/signup/office365/complete';
loginUrl = `${serverUrl}/oauth/office365/mobile_login`;
break;
}
case ViewTypes.OPENID: {
completeUrlPath = '/signup/openid/complete';
loginUrl = `${serverUrl}/oauth/openid/mobile_login`;
break;
}
}
const onLoadEndError = (e: ErrorApi) => {
console.warn('Failed to set store from local data', e); // eslint-disable-line no-console
let errorMessage = e.message;
if (e.url) {
errorMessage += `\nURL: ${e.url}`;
}
setLoginError(errorMessage);
};
const onMMToken = async (token: string) => {
Client4.setToken(token);
asyncDispatch(ssoLogin()).then((result: any) => {
if (result && result.error) {
onLoadEndError(result.error);
return;
}
goToChannel();
}).catch(() => {
setLoginError('');
});
};
const goToChannel = () => {
tracker.initialLoad = Date.now();
scheduleSessionExpiredNotification();
resetToChannel();
};
const scheduleSessionExpiredNotification = () => {
dispatch(scheduleExpiredNotification(intl));
};
const isSSOWithRedirectURLAvailable = isMinimumServerVersion(config.Version, 5, 33, 0);
const props = {
intl,
loginError,
loginUrl,
onCSRFToken: Client4.setCSRF,
onMMToken,
setLoginError,
theme,
};
if (isSSOWithRedirectURLAvailable) {
return (
<SSOWithRedirectURL {...props}/>
);
}
return (
<SSOWithWebView
{...props}
completeUrlPath={completeUrlPath}
serverUrl={serverUrl}
ssoType={ssoType}
/>
);
}
export default React.memo(injectIntl(SSO));

View file

@ -1,300 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {injectIntl, intlShape} from 'react-intl';
import {
Text,
View,
Platform,
} from 'react-native';
import {WebView} from 'react-native-webview';
import CookieManager from 'react-native-cookies';
import {SafeAreaView} from 'react-native-safe-area-context';
import urlParse from 'url-parse';
import {Client4} from '@mm-redux/client';
import {ViewTypes} from 'app/constants';
import Loading from 'app/components/loading';
import StatusBar from 'app/components/status_bar';
import {resetToChannel} from 'app/actions/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import tracker from 'app/utils/time_tracker';
const HEADERS = {
'X-Mobile-App': 'mattermost',
};
const postMessageJS = "window.postMessage(document.body.innerText, '*');";
// Used to make sure that OneLogin forms scale appropriately on both platforms.
const oneLoginFormScalingJS = `
(function() {
var loginPage = document.getElementById('login-page');
var submitButton = document.getElementById('user_submit');
if (loginPage) {
loginPage.setAttribute('style', 'background-repeat: repeat-y;');
}
function resetPadding() {
var mainBody = document.getElementById('body-main');
if (mainBody) {
mainBody.setAttribute('style', 'height: auto; padding: 10px 0;');
}
if (submitButton) {
submitButton.removeEventListener('click', resetPadding);
}
}
resetPadding();
if (submitButton) {
submitButton.addEventListener('click', resetPadding);
}
})();
`;
class SSO extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
scheduleExpiredNotification: PropTypes.func.isRequired,
ssoLogin: PropTypes.func.isRequired,
}).isRequired,
intl: intlShape.isRequired,
serverUrl: PropTypes.string.isRequired,
ssoType: PropTypes.string.isRequired,
theme: PropTypes.object,
};
useWebkit = true;
constructor(props) {
super(props);
this.state = {
error: null,
renderWebView: true,
jsCode: '',
messagingEnabled: false,
};
switch (props.ssoType) {
case ViewTypes.GITLAB:
this.loginUrl = `${props.serverUrl}/oauth/gitlab/mobile_login`;
this.completeUrlPath = '/signup/gitlab/complete';
break;
case ViewTypes.SAML:
this.loginUrl = `${props.serverUrl}/login/sso/saml?action=mobile`;
this.completeUrlPath = '/login/sso/saml';
break;
case ViewTypes.OFFICE365:
this.loginUrl = `${props.serverUrl}/oauth/office365/mobile_login`;
this.completeUrlPath = '/signup/office365/complete';
break;
}
if (Platform.OS === 'ios') {
this.useWebkit = parseInt(Platform.Version, 10) >= 11;
}
}
componentWillUnmount() {
clearTimeout(this.cookiesTimeout);
}
extractCookie = (parsedUrl) => {
const original = urlParse(this.props.serverUrl);
// Check whether we need to set a sub-path
parsedUrl.set('pathname', original.pathname || '');
// Rebuild the server url without query string and/or hash
const url = `${parsedUrl.origin}${parsedUrl.pathname}`;
Client4.setUrl(url);
CookieManager.get(url, true).then((res) => {
const mmtoken = res.MMAUTHTOKEN;
const csrf = res.MMCSRF;
const token = typeof mmtoken === 'object' ? mmtoken.value : mmtoken;
const csrfToken = typeof csrf === 'object' ? csrf.value : csrf;
if (csrfToken) {
Client4.setCSRF(csrfToken);
}
if (token) {
clearTimeout(this.cookiesTimeout);
this.setState({renderWebView: false});
const {
ssoLogin,
} = this.props.actions;
Client4.setToken(token);
ssoLogin().then((result) => {
if (result.error) {
this.onLoadEndError(result.error);
return;
}
this.goToChannel();
});
} else if (this.webView && !this.state.error) {
this.webView.injectJavaScript(postMessageJS);
this.cookiesTimeout = setTimeout(this.extractCookie.bind(null, parsedUrl), 250);
}
});
}
goToChannel = () => {
tracker.initialLoad = Date.now();
this.scheduleSessionExpiredNotification();
resetToChannel();
};
onMessage = (event) => {
try {
const response = JSON.parse(event.nativeEvent.data);
if (response) {
const {
id,
message,
status_code: statusCode,
} = response;
if (id && message && statusCode !== 200) {
clearTimeout(this.cookiesTimeout);
this.setState({error: message});
}
}
} catch (e) {
// do nothing
}
};
onNavigationStateChange = (navState) => {
const {url} = navState;
const nextState = {
messagingEnabled: false,
};
const parsed = urlParse(url);
if (parsed.host.includes('.onelogin.com')) {
nextState.jsCode = oneLoginFormScalingJS;
} else if (parsed.pathname === this.completeUrlPath) {
// To avoid `window.postMessage` conflicts in any of the SSO flows
// we enable the onMessage handler only When the webView navigates to the final SSO URL.
nextState.messagingEnabled = true;
}
this.setState(nextState);
};
onLoadEnd = (event) => {
const url = event.nativeEvent.url;
const parsed = urlParse(url);
let isLastRedirect = url.includes(this.completeUrlPath);
if (this.props.ssoType === ViewTypes.SAML) {
isLastRedirect = isLastRedirect && !parsed.query;
}
if (isLastRedirect) {
this.extractCookie(parsed);
}
};
onLoadEndError = (e) => {
console.warn('Failed to set store from local data', e); // eslint-disable-line no-console
let error = e.message;
if (e.details) {
error += `\n${e.details.message}`;
}
if (e.url) {
error += `\nURL: ${e.url}`;
}
this.setState({error});
};
scheduleSessionExpiredNotification = () => {
const {actions, intl} = this.props;
actions.scheduleExpiredNotification(intl);
};
renderLoading = () => {
return <Loading/>;
};
webViewRef = (ref) => {
this.webView = ref;
};
render() {
const {theme} = this.props;
const {error, messagingEnabled, renderWebView, jsCode} = this.state;
const style = getStyleSheet(theme);
let content;
if (error) {
content = (
<View style={style.errorContainer}>
<Text style={style.errorText}>{error}</Text>
</View>
);
} else if (renderWebView) {
content = (
<WebView
ref={this.webViewRef}
source={{uri: this.loginUrl, headers: HEADERS}}
javaScriptEnabledAndroid={true}
automaticallyAdjustContentInsets={false}
startInLoadingState={true}
onNavigationStateChange={this.onNavigationStateChange}
onShouldStartLoadWithRequest={() => true}
injectedJavaScript={jsCode}
onLoadEnd={this.onLoadEnd}
onMessage={messagingEnabled ? this.onMessage : null}
useSharedProcessPool={false}
cacheEnabled={false}
/>
);
} else {
content = this.renderLoading();
}
return (
<SafeAreaView style={style.container}>
<StatusBar/>
{content}
</SafeAreaView>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
},
errorContainer: {
alignItems: 'center',
flex: 1,
marginTop: 40,
},
errorText: {
color: changeOpacity(theme.centerChannelColor, 0.4),
fontSize: 16,
fontWeight: '400',
lineHeight: 23,
paddingHorizontal: 30,
},
};
});
export default injectIntl(SSO);

View file

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Linking} from 'react-native';
import {renderWithReduxIntl} from 'test/testing_library';
import configureStore from 'test/test_store';
import SSOComponent from './index';
describe('SSO', () => {
const baseProps = {
config: {},
license: {
IsLicensed: 'true',
},
};
test('implement with webview when version is less than 5.32 version', async () => {
const store = await configureStore({
entities: {
general: {
config: {
Version: '5.30.0',
},
},
},
});
const basicWrapper = renderWithReduxIntl(<SSOComponent {...baseProps}/>, store);
expect(basicWrapper.queryByTestId('sso.webview')).toBeTruthy();
expect(basicWrapper.queryByTestId('sso.redirect_url')).toBeFalsy();
});
test('implement with OS browser & redirect url from version 5.33', async () => {
(Linking.openURL as jest.Mock).mockResolvedValueOnce('');
const store = await configureStore({
entities: {
general: {
config: {
Version: '5.33.0',
},
},
},
});
const basicWrapper = renderWithReduxIntl(<SSOComponent {...baseProps}/>, store);
expect(basicWrapper.queryByTestId('sso.webview')).toBeFalsy();
expect(basicWrapper.queryByTestId('sso.redirect_url')).toBeTruthy();
});
});

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
import FormattedText from 'app/components/formatted_text';
import SSOWithRedirectURL from './sso_with_redirect_url';
describe('SSO with redirect url', () => {
const baseProps = {
customUrlScheme: 'mmauth://',
intl: {},
loginError: '',
loginUrl: '',
onCSRFToken: jest.fn(),
onMMToken: jest.fn(),
setLoginError: jest.fn(),
theme: Preferences.THEMES.default,
};
test('should show message when user navigates to the page', () => {
const wrapperWithBaseProps = shallow(<SSOWithRedirectURL {...baseProps}/>);
expect(wrapperWithBaseProps.find(FormattedText).find({id: 'mobile.oauth.switch_to_browser'}).exists()).toBe(true);
});
test('should show "try again" and hide default message when error text is displayed', () => {
const wrapperWithBaseProps = shallow(<SSOWithRedirectURL {...baseProps}/>);
expect(wrapperWithBaseProps.find(FormattedText).find({id: 'mobile.oauth.try_again'}).exists()).toBe(false);
const wrapper = shallow(
<SSOWithRedirectURL
{...baseProps}
loginError='some error'
/>,
);
expect(wrapper.find(FormattedText).find({id: 'mobile.oauth.try_again'}).exists()).toBe(true);
expect(wrapper.find(FormattedText).find({id: 'mobile.oauth.switch_to_browser'}).exists()).toBe(false);
});
});

View file

@ -0,0 +1,179 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {intlShape} from 'react-intl';
import {Linking, Text, TouchableOpacity, View} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {SafeAreaView} from 'react-native-safe-area-context';
import urlParse from 'url-parse';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {setDeepLinkURL} from '@actions/views/root';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import {Theme} from '@mm-redux/types/preferences';
import Store from '@store/store';
import {tryOpenURL} from '@utils/url';
interface SSOWithRedirectURLProps {
intl: typeof intlShape;
loginError: string;
loginUrl: string;
onCSRFToken: (token: string) => void;
onMMToken: (token: string) => void;
setLoginError: (value: string) => void;
theme: Theme
}
function SSOWithRedirectURL({
intl,
loginError,
loginUrl,
onCSRFToken,
onMMToken,
setLoginError,
theme,
}: SSOWithRedirectURLProps) {
const [error, setError] = React.useState<string>('');
const style = getStyleSheet(theme);
let customUrlScheme = 'mmauth://';
if (DeviceInfo.getBundleId && DeviceInfo.getBundleId().includes('rnbeta')) {
customUrlScheme = 'mmauthbeta://';
}
const redirectUrl = customUrlScheme + 'callback';
const init = (resetErrors?: boolean) => {
if (resetErrors !== false) {
setError('');
setLoginError('');
}
const parsedUrl = urlParse(loginUrl, true);
parsedUrl.set('query', {
...parsedUrl.query,
redirect_to: redirectUrl,
});
const url = parsedUrl.toString();
const onError = () => setError(
intl.formatMessage({
id: 'mobile.oauth.failed_to_open_link',
defaultMessage: 'The link failed to open. Please try again.',
}),
);
tryOpenURL(url, onError);
};
const onURLChange = ({url}: { url: string }) => {
if (url && url.startsWith(redirectUrl)) {
Store?.redux?.dispatch(setDeepLinkURL(''));
const parsedUrl = urlParse(url, true);
if (parsedUrl.query && parsedUrl.query.MMCSRF && parsedUrl.query.MMAUTHTOKEN) {
onCSRFToken(parsedUrl.query.MMCSRF);
onMMToken(parsedUrl.query.MMAUTHTOKEN);
} else {
setError(
intl.formatMessage({
id: 'mobile.oauth.failed_to_login',
defaultMessage: 'Your login attempt failed. Please try again.',
}),
);
}
}
};
React.useEffect(() => {
Linking.addEventListener('url', onURLChange);
init(false);
return () => {
Linking.removeEventListener('url', onURLChange);
};
}, []);
return (
<SafeAreaView
style={style.container}
testID='sso.redirect_url'
>
{loginError || error ? (
<View style={style.errorContainer}>
<View style={style.errorTextContainer}>
<Text style={style.errorText}>
{`${loginError || error}.`}
</Text>
</View>
<TouchableOpacity onPress={() => init()}>
<FormattedText
id='mobile.oauth.try_again'
defaultMessage='Try again'
style={style.button}
/>
</TouchableOpacity>
</View>
) : (
<View style={style.infoContainer}>
<FormattedText
id='mobile.oauth.switch_to_browser'
defaultMessage='Please use your browser to complete the login'
style={style.infoText}
/>
<TouchableOpacity onPress={() => init()}>
<FormattedText
id='mobile.oauth.restart_login'
defaultMessage='Restart login'
style={style.button}
/>
</TouchableOpacity>
<Loading/>
</View>
)}
</SafeAreaView>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
errorContainer: {
alignItems: 'center',
flex: 1,
marginTop: 40,
},
errorTextContainer: {
marginBottom: 12,
},
errorText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 16,
fontWeight: '400',
lineHeight: 23,
},
infoContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
marginTop: 40,
},
infoText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 16,
fontWeight: '400',
lineHeight: 23,
marginBottom: 6,
},
button: {
backgroundColor: theme.buttonBg,
color: theme.buttonColor,
fontSize: 16,
paddingHorizontal: 9,
paddingVertical: 9,
marginTop: 3,
},
};
});
export default SSOWithRedirectURL;

View file

@ -0,0 +1,256 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {intlShape} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import CookieManager from 'react-native-cookies';
import {SafeAreaView} from 'react-native-safe-area-context';
import {WebView} from 'react-native-webview';
import {WebViewErrorEvent, WebViewMessageEvent, WebViewNavigation, WebViewNavigationEvent} from 'react-native-webview/lib/WebViewTypes';
import urlParse from 'url-parse';
import {Client4} from '@mm-redux/client';
import type {Theme} from '@mm-redux/types/preferences';
import Loading from 'app/components/loading';
import StatusBar from 'app/components/status_bar';
import {ViewTypes} from 'app/constants';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {popTopScreen} from '@actions/navigation';
const HEADERS = {
'X-Mobile-App': 'mattermost',
};
const postMessageJS = "window.postMessage(document.body.innerText, '*');";
// Used to make sure that OneLogin forms scale appropriately on both platforms.
const oneLoginFormScalingJS = `
(function() {
var loginPage = document.getElementById('login-page');
var submitButton = document.getElementById('user_submit');
if (loginPage) {
loginPage.setAttribute('style', 'background-repeat: repeat-y;');
}
function resetPadding() {
var mainBody = document.getElementById('body-main');
if (mainBody) {
mainBody.setAttribute('style', 'height: auto; padding: 10px 0;');
}
if (submitButton) {
submitButton.removeEventListener('click', resetPadding);
}
}
resetPadding();
if (submitButton) {
submitButton.addEventListener('click', resetPadding);
}
})();
`;
interface SSOWithWebViewProps {
completeUrlPath: string;
intl: typeof intlShape;
loginError: string;
loginUrl: string;
onCSRFToken: (token: string) => void;
onMMToken: (token: string) => void;
serverUrl: string;
ssoType: string;
theme: Theme
}
type CookieResponseType = {
MMAUTHTOKEN: string | {
value: string
};
MMCSRF: string | {
value: string
};
}
function SSOWithWebView({completeUrlPath, intl, loginError, loginUrl, onCSRFToken, onMMToken, serverUrl, ssoType, theme}: SSOWithWebViewProps) {
const style = getStyleSheet(theme);
const [error, setError] = React.useState(null);
const [jsCode, setJSCode] = React.useState('');
const [messagingEnabled, setMessagingEnabled] = React.useState(false);
const [shouldRenderWebView, setShouldRenderWebView] = React.useState(true);
const cookiesTimeout = React.useRef<number>();
const webView = React.useRef<WebView>(null);
React.useEffect(() => {
return () => {
if (cookiesTimeout.current) {
clearTimeout(cookiesTimeout.current);
}
};
}, []);
const extractCookie = (parsedUrl: urlParse) => {
try {
const original = urlParse(serverUrl);
// Check whether we need to set a sub-path
parsedUrl.set('pathname', original.pathname || '');
// Rebuild the server url without query string and/or hash
const url = `${parsedUrl.origin}${parsedUrl.pathname}`;
Client4.setUrl(url);
CookieManager.get(url, true).then((res: CookieResponseType) => {
const mmtoken = res.MMAUTHTOKEN;
const csrf = res.MMCSRF;
const token = typeof mmtoken === 'object' ? mmtoken.value : mmtoken;
const csrfToken = typeof csrf === 'object' ? csrf.value : csrf;
if (csrfToken) {
onCSRFToken(csrfToken);
}
if (token) {
onMMToken(token);
if (cookiesTimeout.current) {
clearTimeout(cookiesTimeout.current);
}
setShouldRenderWebView(false);
} else if (webView.current && !error) {
webView.current.injectJavaScript(postMessageJS);
cookiesTimeout.current = setTimeout(extractCookie.bind(null, parsedUrl), 250);
}
});
} catch (e) {
Alert.alert(
intl.formatMessage({
id: 'mobile.oauth.something_wrong',
defaultMessage: 'Something went wrong',
}),
'',
[{
text: intl.formatMessage({
id: 'mobile.oauth.something_wrong.okButon',
defaultMessage: 'Ok',
}),
onPress: () => {
popTopScreen();
},
}],
);
}
};
const onMessage = (event: WebViewMessageEvent) => {
try {
const response = JSON.parse(event.nativeEvent.data);
if (response) {
const {
id,
message,
status_code: statusCode,
} = response;
if (id && message && statusCode !== 200) {
if (cookiesTimeout.current) {
clearTimeout(cookiesTimeout.current);
}
setError(message);
}
}
} catch (e) {
// do nothing
}
};
const onNavigationStateChange = (navState: WebViewNavigation) => {
const {url} = navState;
let isMessagingEnabled = false;
const parsed = urlParse(url);
if (parsed.host.includes('.onelogin.com')) {
setJSCode(oneLoginFormScalingJS);
} else if (parsed.pathname === completeUrlPath) {
// To avoid `window.postMessage` conflicts in any of the SSO flows
// we enable the onMessage handler only When the webView navigates to the final SSO URL.
isMessagingEnabled = true;
}
setMessagingEnabled(isMessagingEnabled);
};
const onLoadEnd = (event: WebViewNavigationEvent | WebViewErrorEvent) => {
const url = event.nativeEvent.url;
const parsed = urlParse(url);
let isLastRedirect = url.includes(completeUrlPath);
if (ssoType === ViewTypes.SAML) {
isLastRedirect = isLastRedirect && !parsed.query;
}
if (isLastRedirect) {
extractCookie(parsed);
}
};
const renderWebView = () => {
if (shouldRenderWebView) {
const userAgent = ssoType === ViewTypes.GOOGLE ? 'Mozilla/5.0 (Linux; Android 10; Android SDK built for x86 Build/LMY48X) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/81.0.4044.117 Mobile Safari/608.2.11' : undefined;
return (
<WebView
automaticallyAdjustContentInsets={false}
cacheEnabled={false}
injectedJavaScript={jsCode}
javaScriptEnabled={true}
onLoadEnd={onLoadEnd}
onMessage={messagingEnabled ? onMessage : undefined}
onNavigationStateChange={onNavigationStateChange}
onShouldStartLoadWithRequest={() => true}
ref={webView}
source={{uri: loginUrl, headers: HEADERS}}
startInLoadingState={true}
userAgent={userAgent}
useSharedProcessPool={false}
/>
);
}
return <Loading/>;
};
return (
<SafeAreaView
style={style.container}
testID='sso.webview'
>
<StatusBar/>
{error || loginError ? (
<View style={style.errorContainer}>
<Text style={style.errorText}>{error || loginError}</Text>
</View>
) : renderWebView()}
</SafeAreaView>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
errorContainer: {
alignItems: 'center',
flex: 1,
marginTop: 40,
},
errorText: {
color: changeOpacity(theme.centerChannelColor, 0.4),
fontSize: 16,
fontWeight: '400',
lineHeight: 23,
paddingHorizontal: 30,
},
};
});
export default SSOWithWebView;

View file

@ -362,8 +362,15 @@
"mobile.notification_settings.modal_cancel": "CANCEL",
"mobile.notification_settings.modal_save": "SAVE",
"mobile.notification_settings.ooo_auto_responder": "Automatic Direct Message Replies",
"mobile.notification_settings.save_failed_description": "The notification settings failed to save due to a connection issue, please try again.",
"mobile.notification_settings.save_failed_description": "The notification settings failed to save due to a connection issue. Please try again.",
"mobile.notification_settings.save_failed_title": "Connection issue",
"mobile.oauth.failed_to_login": "Your login attempt failed. Please try again.",
"mobile.oauth.failed_to_open_link": "The link failed to open. Please try again.",
"mobile.oauth.restart_login": "Restart login",
"mobile.oauth.something_wrong": "Something went wrong",
"mobile.oauth.something_wrong.okButon": "OK",
"mobile.oauth.switch_to_browser": "Please use your browser to complete the login process.",
"mobile.oauth.try_again": "Try again",
"mobile.offlineIndicator.connected": "Connected",
"mobile.offlineIndicator.connecting": "Connecting...",
"mobile.offlineIndicator.offline": "No internet connection",
@ -591,6 +598,7 @@
"sidebar.types.recent": "RECENT ACTIVITY",
"sidebar.unreads": "More unreads",
"signup.email": "Email and Password",
"signup.google": "Google Apps",
"signup.office365": "Office 365",
"status_dropdown.set_away": "Away",
"status_dropdown.set_dnd": "Do Not Disturb",

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

View file

@ -429,12 +429,13 @@ platform :ios do
# Set the deep link prefix
app_scheme = ENV['APP_SCHEME'] || 'mattermost'
app_auth_scheme = ENV['BETA_BUILD'] == 'true' ? 'mmauthbeta' : 'mmauth'
update_info_plist(
xcodeproj: './ios/Mattermost.xcodeproj',
plist_path: 'Mattermost/Info.plist',
block: proc do |plist|
urlScheme = plist["CFBundleURLTypes"].find{|scheme| scheme["CFBundleURLName"] == "com.mattermost"}
urlScheme[:CFBundleURLSchemes] = [app_scheme]
urlScheme[:CFBundleURLSchemes] = [app_scheme, app_auth_scheme]
end
)
@ -660,6 +661,14 @@ platform :android do
new_string: "scheme=\'#{app_scheme}\'"
)
if ENV['BETA_BUILD'] != 'true'
find_replace_string(
path_to_file: "./android/app/src/main/AndroidManifest.xml",
old_string: 'scheme="mmauthbeta"',
new_string: 'scheme="mmauth"',
)
end
beta_dir = './android/app/src/main/java/com/mattermost/rnbeta/'
release_dir = "./android/app/src/main/java/#{package_id.gsub '.', '/'}/"
if ENV['BUILD_FOR_RELEASE'] == 'true'

View file

@ -32,6 +32,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>mattermost</string>
<string>mmauthbeta</string>
</array>
</dict>
</array>