Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions src/api/settings/getServerSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ import type { ApiResponseSuccess } from '../transportTypes';
import { apiGet } from '../apiFetch';

// This corresponds to AUTHENTICATION_FLAGS in zulip/zulip:zerver/models.py .
export type AuthenticationMethods = {|
dev: boolean,
github: boolean,
google: boolean,
ldap: boolean,
password: boolean,
azuread: boolean,
remoteuser: boolean,
|};
export type AuthenticationMethods = {
dev?: boolean,
github?: boolean,
google?: boolean,
ldap?: boolean,
password?: boolean,
azuread?: boolean,
remoteuser?: boolean,
...
};

export type ApiResponseServerSettings = {|
...ApiResponseSuccess,
Expand Down
28 changes: 0 additions & 28 deletions src/start/AuthButton.js

This file was deleted.

172 changes: 118 additions & 54 deletions src/start/AuthScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,105 @@

import React, { PureComponent } from 'react';
import { Linking } from 'react-native';
import parseURL from 'url-parse';
import type { NavigationScreenProp } from 'react-navigation';

import type { Dispatch, ApiResponseServerSettings } from '../types';
import type { AuthenticationMethods, Dispatch, ApiResponseServerSettings } from '../types';
import { IconPrivate, IconGoogle, IconGitHub, IconWindows, IconTerminal } from '../common/Icons';
import type { IconType } from '../common/Icons';
import { connect } from '../react-redux';
import { Centerer, Screen } from '../common';
import styles from '../styles';
import { Centerer, Screen, ZulipButton } from '../common';
import { getCurrentRealm } from '../selectors';
import RealmInfo from './RealmInfo';
import AuthButton from './AuthButton';
import { getFullUrl } from '../utils/url';
import { extractApiKey } from '../utils/encoding';
import { generateOtp, openBrowser, closeBrowser } from './oauth';
import { activeAuthentications } from './authentications';
import * as webAuth from './webAuth';
import { loginSuccess, navigateToDev, navigateToPassword } from '../actions';

/**
* Describes a method for authenticating to the server.
*
* Different servers and orgs/realms accept different sets of auth methods,
* described in the /server_settings response; see api.getServerSettings
* and https://zulipchat.com/api/server-settings .
*/
type AuthenticationMethodDetails = {|
/** An identifier-style name used in the /server_settings API. */
name: string,

/** A name to show in the UI. */
displayName: string,

Icon: IconType,
action: 'dev' | 'password' | {| url: string |},
|};

const authentications: AuthenticationMethodDetails[] = [
{
name: 'dev',
displayName: 'dev account',
Icon: IconTerminal,
action: 'dev',
},
{
name: 'password',
displayName: 'password',
Icon: IconPrivate,
action: 'password',
},
{
name: 'ldap',
displayName: 'password',
Icon: IconPrivate,
action: 'password',
},
{
name: 'google',
displayName: 'Google',
Icon: IconGoogle,
// Server versions through 2.0 accept only this URL for Google auth.
// Since server commit 2.0.0-2478-ga43b231f9 , both this URL and the new
// accounts/login/social/google are accepted; see zulip/zulip#13081 .
action: { url: 'accounts/login/google/' },
},
{
name: 'github',
displayName: 'GitHub',
Icon: IconGitHub,
action: { url: 'accounts/login/social/github' },
},
{
name: 'azuread',
displayName: 'Azure AD',
Icon: IconWindows,
action: { url: '/accounts/login/social/azuread-oauth2' },
},
{
name: 'remoteuser',
displayName: 'SSO',
Icon: IconPrivate,
action: { url: 'accounts/login/sso/' },
},
];

/** Exported for tests only. */
export const activeAuthentications = (
authenticationMethods: AuthenticationMethods,
): AuthenticationMethodDetails[] => {
const result = [];
authentications.forEach(auth => {
if (!authenticationMethods[auth.name]) {
return;
}
if (auth.name === 'ldap' && authenticationMethods.password === true) {
// For either of these, we show a button that looks and behaves
// exactly the same. When both are enabled, dedupe them.
return;
}
result.push(auth);
});
return result;
};

type Props = $ReadOnly<{|
dispatch: Dispatch,
realm: string,
Expand All @@ -39,48 +123,37 @@ type LinkingEvent = {

class AuthScreen extends PureComponent<Props> {
componentDidMount = () => {
Linking.addEventListener('url', this.endOAuth);
Linking.addEventListener('url', this.endWebAuth);
Linking.getInitialURL().then((initialUrl: ?string) => {
if (initialUrl !== null && initialUrl !== undefined) {
this.endOAuth({ url: initialUrl });
this.endWebAuth({ url: initialUrl });
}
});

const authList = activeAuthentications(
this.props.navigation.state.params.serverSettings.authentication_methods,
);
if (authList.length === 1) {
// $FlowFixMe
this[authList[0].handler]();
this.handleAuth(authList[0]);
}
};

componentWillUnmount = () => {
Linking.removeEventListener('url', this.endOAuth);
Linking.removeEventListener('url', this.endWebAuth);
};

beginOAuth = async (url: string) => {
otp = await generateOtp();
openBrowser(`${this.props.realm}/${url}`, otp);
beginWebAuth = async (url: string) => {
otp = await webAuth.generateOtp();
webAuth.openBrowser(`${this.props.realm}/${url}`, otp);
};

endOAuth = (event: LinkingEvent) => {
closeBrowser();
endWebAuth = (event: LinkingEvent) => {
webAuth.closeBrowser();

const { dispatch, realm } = this.props;
const url = parseURL(event.url, true);

// callback format expected: zulip://login?realm={}&email={}&otp_encrypted_api_key={}
if (
url.host === 'login'
&& url.query.realm === realm
&& otp
&& url.query.email
&& url.query.otp_encrypted_api_key
&& url.query.otp_encrypted_api_key.length === otp.length
) {
const apiKey = extractApiKey(url.query.otp_encrypted_api_key, otp);
dispatch(loginSuccess(realm, url.query.email, apiKey));
const auth = webAuth.authFromCallbackUrl(event.url, otp, realm);
if (auth) {
dispatch(loginSuccess(auth.realm, auth.email, auth.apiKey));
}
};

Expand All @@ -93,23 +166,15 @@ class AuthScreen extends PureComponent<Props> {
this.props.dispatch(navigateToPassword(serverSettings.require_email_format_usernames));
};

handleGoogle = () => {
// Server versions through 2.0 accept only this URL for Google auth.
// Since server commit 2.0.0-2478-ga43b231f9 , both this URL and the new
// accounts/login/social/google are accepted; see zulip/zulip#13081 .
this.beginOAuth('accounts/login/google/');
};

handleGitHub = () => {
this.beginOAuth('accounts/login/social/github');
};

handleAzureAD = () => {
this.beginOAuth('/accounts/login/social/azuread-oauth2');
};

handleSso = () => {
this.beginOAuth('accounts/login/sso/');
handleAuth = (method: AuthenticationMethodDetails) => {
const { action } = method;
if (action === 'dev') {
this.handleDevAuth();
} else if (action === 'password') {
this.handlePassword();
} else {
this.beginWebAuth(action.url);
}
};

render() {
Expand All @@ -123,14 +188,13 @@ class AuthScreen extends PureComponent<Props> {
iconUrl={getFullUrl(serverSettings.realm_icon, this.props.realm)}
/>
{activeAuthentications(serverSettings.authentication_methods).map(auth => (
<AuthButton
key={auth.method}
name={auth.name}
<ZulipButton
key={auth.name}
style={styles.halfMarginTop}
secondary
text={`Log in with ${auth.displayName}`}
Icon={auth.Icon}
onPress={
// $FlowFixMe
this[auth.handler]
}
onPress={() => this.handleAuth(auth)}
/>
))}
</Centerer>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { activeAuthentications } from '../authentications';
/* @flow strict-local */

import { activeAuthentications } from '../AuthScreen';

describe('activeAuthentications', () => {
test('empty auth methods object result in no available authentications', () => {
Expand Down
25 changes: 25 additions & 0 deletions src/start/__tests__/webAuth-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* @flow strict-local */
import { authFromCallbackUrl } from '../webAuth';

describe('authFromCallbackUrl', () => {
const otp = '13579bdf';
const realm = 'https://chat.example';

test('success', () => {
const url = `zulip://login?realm=${realm}&email=a@b&otp_encrypted_api_key=2636fdeb`;
expect(authFromCallbackUrl(url, otp, realm)).toEqual({ realm, email: 'a@b', apiKey: '5af4' });
});

test('wrong realm', () => {
const url =
'zulip://login?realm=https://other.example.org&email=a@b&otp_encrypted_api_key=2636fdeb';
expect(authFromCallbackUrl(url, otp, realm)).toEqual(null);
});

test('not login', () => {
// Hypothetical link that isn't a login... but somehow with all the same
// query params, for extra confusion for good measure.
const url = `zulip://message?realm=${realm}&email=a@b&otp_encrypted_api_key=2636fdeb`;
expect(authFromCallbackUrl(url, otp, realm)).toEqual(null);
});
});
65 changes: 0 additions & 65 deletions src/start/authentications.js

This file was deleted.

Loading