diff --git a/src/api/settings/getServerSettings.js b/src/api/settings/getServerSettings.js index e00eb42f054..af7a94df294 100644 --- a/src/api/settings/getServerSettings.js +++ b/src/api/settings/getServerSettings.js @@ -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, diff --git a/src/start/AuthButton.js b/src/start/AuthButton.js deleted file mode 100644 index 885d9779260..00000000000 --- a/src/start/AuthButton.js +++ /dev/null @@ -1,28 +0,0 @@ -/* @flow strict-local */ -import React, { PureComponent } from 'react'; - -import { ZulipButton } from '../common'; -import type { IconType } from '../common/Icons'; -import styles from '../styles'; - -type Props = $ReadOnly<{| - name: string, - Icon: IconType, - onPress: () => void, -|}>; - -export default class AuthButton extends PureComponent { - render() { - const { name, Icon, onPress } = this.props; - - return ( - - ); - } -} diff --git a/src/start/AuthScreen.js b/src/start/AuthScreen.js index 0e70935f4d0..58b34e9497a 100644 --- a/src/start/AuthScreen.js +++ b/src/start/AuthScreen.js @@ -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, @@ -39,10 +123,10 @@ type LinkingEvent = { class AuthScreen extends PureComponent { 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 }); } }); @@ -50,37 +134,26 @@ class AuthScreen extends PureComponent { 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)); } }; @@ -93,23 +166,15 @@ class AuthScreen extends PureComponent { 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() { @@ -123,14 +188,13 @@ class AuthScreen extends PureComponent { iconUrl={getFullUrl(serverSettings.realm_icon, this.props.realm)} /> {activeAuthentications(serverSettings.authentication_methods).map(auth => ( - this.handleAuth(auth)} /> ))} diff --git a/src/start/__tests__/authentications-test.js b/src/start/__tests__/AuthScreen-test.js similarity index 94% rename from src/start/__tests__/authentications-test.js rename to src/start/__tests__/AuthScreen-test.js index ac4df25c1f8..4141eac1004 100644 --- a/src/start/__tests__/authentications-test.js +++ b/src/start/__tests__/AuthScreen-test.js @@ -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', () => { diff --git a/src/start/__tests__/webAuth-test.js b/src/start/__tests__/webAuth-test.js new file mode 100644 index 00000000000..4f64b856c88 --- /dev/null +++ b/src/start/__tests__/webAuth-test.js @@ -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); + }); +}); diff --git a/src/start/authentications.js b/src/start/authentications.js deleted file mode 100644 index e61022bde14..00000000000 --- a/src/start/authentications.js +++ /dev/null @@ -1,65 +0,0 @@ -/* @flow strict-local */ -import type { AuthenticationMethods } from '../types'; -import { IconPrivate, IconGoogle, IconGitHub, IconWindows, IconTerminal } from '../common/Icons'; -import type { IconType } from '../common/Icons'; - -type AuthenticationMethodDetails = {| - method: string, - name: string, - Icon: IconType, - handler: string, -|}; - -const authentications: AuthenticationMethodDetails[] = [ - { - method: 'dev', - name: 'dev account', - Icon: IconTerminal, - handler: 'handleDevAuth', - }, - { - method: 'password', - name: 'password', - Icon: IconPrivate, - handler: 'handlePassword', - }, - { - method: 'ldap', - name: 'password', - Icon: IconPrivate, - handler: 'handlePassword', - }, - { - method: 'google', - name: 'Google', - Icon: IconGoogle, - handler: 'handleGoogle', - }, - { - method: 'github', - name: 'GitHub', - Icon: IconGitHub, - handler: 'handleGitHub', - }, - { - method: 'azuread', - name: 'Azure AD', - Icon: IconWindows, - handler: 'handleAzureAD', - }, - { - method: 'remoteuser', - name: 'SSO', - Icon: IconPrivate, - handler: 'handleSso', - }, -]; - -export const activeAuthentications = ( - authenticationMethods: AuthenticationMethods, -): AuthenticationMethodDetails[] => - authentications.filter( - auth => - authenticationMethods[auth.method] - && (auth.method !== 'ldap' || !authenticationMethods.password), - ); diff --git a/src/start/oauth.js b/src/start/oauth.js deleted file mode 100644 index b634191aa44..00000000000 --- a/src/start/oauth.js +++ /dev/null @@ -1,36 +0,0 @@ -/* @flow strict-local */ -import { NativeModules, Platform } from 'react-native'; -import SafariView from 'react-native-safari-view'; - -import openLink from '../utils/openLink'; -import { base64ToHex } from '../utils/encoding'; - -// Generate a one time pad (OTP) which the server XORs the API key with -// in its response to protect against credentials intercept -export const generateOtp = async () => { - if (Platform.OS === 'android') { - return new Promise((resolve, reject) => { - NativeModules.RNSecureRandom.randomBase64(32, (err, result) => { - if (err) { - reject(err); - } - resolve(base64ToHex(result)); - }); - }); - } else { - const rand = await NativeModules.UtilManager.randomBase64(32); - return base64ToHex(rand); - } -}; - -export const openBrowser = (url: string, otp: string) => { - openLink(`${url}?mobile_flow_otp=${otp}`); -}; - -export const closeBrowser = () => { - if (Platform.OS === 'android') { - NativeModules.CloseAllCustomTabsAndroid.closeAll(); - } else { - SafariView.dismiss(); - } -}; diff --git a/src/start/webAuth.js b/src/start/webAuth.js new file mode 100644 index 00000000000..7c6841d601a --- /dev/null +++ b/src/start/webAuth.js @@ -0,0 +1,85 @@ +/* @flow strict-local */ +import { NativeModules, Platform } from 'react-native'; +import SafariView from 'react-native-safari-view'; +import parseURL from 'url-parse'; + +import type { Auth } from '../types'; +import openLink from '../utils/openLink'; +import { base64ToHex, hexToAscii, xorHexStrings } from '../utils/encoding'; + +/* + Logic for authenticating the user to Zulip through a browser. + + Specifically, this handles auth flows we don't know the specifics of + here in the app's code. + + To handle that, we send the user to some URL in a browser, so they can go + through whatever flow the server (or an auth provider it redirects them to + in turn) wants to take them through. + + To close the loop when the authentication is complete, there's a + particular protocol we carry out with the Zulip server, involving + `zulip://` URLs and XOR-ing with a one-time pad named `mobile_flow_otp`. + No docs on this protocol seem to exist; see the implementations here + and in the server. + */ + +// Generate a one time pad (OTP) which the server XORs the API key with +// in its response to protect against credentials intercept +export const generateOtp = async () => { + if (Platform.OS === 'android') { + return new Promise((resolve, reject) => { + NativeModules.RNSecureRandom.randomBase64(32, (err, result) => { + if (err) { + reject(err); + } + resolve(base64ToHex(result)); + }); + }); + } else { + const rand = await NativeModules.UtilManager.randomBase64(32); + return base64ToHex(rand); + } +}; + +export const openBrowser = (url: string, otp: string) => { + openLink(`${url}?mobile_flow_otp=${otp}`); +}; + +export const closeBrowser = () => { + if (Platform.OS === 'android') { + NativeModules.CloseAllCustomTabsAndroid.closeAll(); + } else { + SafariView.dismiss(); + } +}; + +/** + * Decode an API key from the Zulip mobile-auth-via-web protocol. + * + * Corresponds to `otp_decrypt_api_key` on the server. + */ +const extractApiKey = (encoded: string, otp: string) => hexToAscii(xorHexStrings(encoded, otp)); + +export const authFromCallbackUrl = ( + callbackUrl: string, + otp: string, + realm: string, +): Auth | null => { + const url = parseURL(callbackUrl, 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); + return { realm, email: url.query.email, apiKey }; + } + + return null; +}; diff --git a/src/utils/__tests__/encoding-test.js b/src/utils/__tests__/encoding-test.js index c916b136fc2..90db5e558f7 100644 --- a/src/utils/__tests__/encoding-test.js +++ b/src/utils/__tests__/encoding-test.js @@ -7,7 +7,6 @@ import { asciiToHex, xorHexStrings, base64Utf8Encode, - extractApiKey, } from '../encoding'; describe('base64ToHex', () => { @@ -78,11 +77,3 @@ describe('base64Utf8Encode', () => { expect(result).toBe(expected); }); }); -describe('extractApiKey', () => { - test('correctly extracts an API key that has been XORed with a OTP', () => { - const key = 'testing'; - const otp = 'A8348A93A83493'; - const encoded = xorHexStrings(asciiToHex(key), otp); - expect(extractApiKey(encoded, otp)).toBe(key); - }); -}); diff --git a/src/utils/encoding.js b/src/utils/encoding.js index 0fa6f47c9c5..0a4f6ed5019 100644 --- a/src/utils/encoding.js +++ b/src/utils/encoding.js @@ -53,8 +53,3 @@ export const base64Utf8Encode = (text: string): string => // We use `base64.encode` because `btoa` is unavailable in the JS // environment provided by RN on iOS. base64.encode(unescape(encodeURIComponent(text))); - -// Extract an API key encoded as a hex string XOR'ed with a one time pad (OTP) -// (this is used during the OAuth flow) -export const extractApiKey = (encoded: string, otp: string) => - hexToAscii(xorHexStrings(encoded, otp)); diff --git a/tools/spellcheck.eslintrc.yaml b/tools/spellcheck.eslintrc.yaml index f7eeb6ef98b..68cee68201b 100644 --- a/tools/spellcheck.eslintrc.yaml +++ b/tools/spellcheck.eslintrc.yaml @@ -108,7 +108,6 @@ rules: - nonexisting - notif - num - - oauth - ok - otp - overlayed