diff --git a/docs/howto/dev-server.md b/docs/howto/dev-server.md
index 3eff7657138..a4c61490703 100644
--- a/docs/howto/dev-server.md
+++ b/docs/howto/dev-server.md
@@ -202,7 +202,7 @@ This will be `http://ADDRESS:9991`, where `ADDRESS` is the address you
identified in step 2. (Be sure to type the `http://`.)
This should get you the login screen! Unless you're working on the login
-flow itself, tap "Log in with dev account"; then pick any user to log in as.
+flow itself, tap "Sign in with dev account"; then pick any user to log in as.
If you need to work more closely with authentication systems, or if you need
to use the [Zulip REST API][rest-api], which requires an API key, this
diff --git a/docs/howto/ios-tips.md b/docs/howto/ios-tips.md
index d8685386e8f..b6089bc5a21 100644
--- a/docs/howto/ios-tips.md
+++ b/docs/howto/ios-tips.md
@@ -84,3 +84,87 @@ It seems like there's some caching strategy to avoid fetching
`.podspec` files unnecessarily, potentially with network requests.
(See
[discussion](https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3548.20RN.20v0.2E60.2E0.20upgrade/near/896746).)
+
+## Sign in with Apple
+
+To set up your [development server](./dev-server.md) to use Apple
+authentication ("Sign in with Apple"), you'll want to follow almost
+[these
+steps](https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#sign-in-with-apple),
+but with a few things to keep in mind:
+
+- If you don't have your own Apple Developer account (there's an
+ annual fee), please ask Greg to set up test credentials and send
+ them to you.
+ These will be associated with the Kandra team, so
+ [please](https://chat.zulip.org/#narrow/stream/3-backend/topic/apple.20auth/near/915391)
+ let him know when you're finished with the credentials so he can
+ revoke them. Please don't abuse them with deliberate spam, as
+ that goes on our reputation.
+- Use the domain `zulipdev.com` where Apple asks for a domain;
+ [`localhost` won't
+ work](https://chat.zulip.org/#narrow/stream/3-backend/topic/Apple.20Auth/near/831533).
+ On the public Internet, `zulipdev.com` resolves to `127.0.0.1`.
+ - `127.0.0.1` (also what `localhost` points to) points to the
+ machine you're on. When you're on a physical device, that's the
+ device itself, not the device (your computer) that's running the
+ dev server. So you won't be able to connect using `zulipdev.com`
+ on a physical device.
+ - Empirically, there's no problem using the iOS simulator on the
+ computer running the dev server; it seems the iOS simulator shares
+ its network interface with the computer it's running on. To use
+ the native flow, you will be able to sign into the simulator at
+ the "device" level just as you would on a real device.
+ - Temporarily allow the app to access `http://zulipdev.com` as
+ described in the section on `NSAppTransportSecurity` exceptions,
+ below.
+
+To test the native flow, which uses an Apple ID you've authenticated
+with in System Preferences, go to the ZulipMobile target in the
+project and targets list, and, under General > Identity, set the
+Bundle Identifier field to your development App ID (a.k.a. Bundle ID).
+If you've already installed a build that used the canonical Bundle
+Identifier, you'll see two app icons on your home screen. Be sure to
+open the correct one; it might be easiest to delete them both and
+reinstall to prevent any doubt.
+
+You should now be able to enter `http://zulipdev.com:9991` (not
+`https://`), see the "Sign in with Apple" button, and use it
+successfully.
+
+## Adding `http://` exceptions to `NSAppTransportSecurity` in `Info.plist`
+
+If you need to connect to `http://zulipdev.com` or another host with
+the insecure `http://`, you'll need to tell the app to make an
+exception under iOS's "App Transport Security", either to allow access
+any host with `http://`, or just to specific domains.
+
+These exceptions should never be committed to master, as there aren't
+any insecure domains we want to connect to in production.
+
+To add an exception for the `zulipdev.com` domain, add the following
+in `ios/ZulipMobile/Info.plist`:
+
+```diff
+ NSAppTransportSecurity
+
+ NSExceptionDomains
+
+ localhost
+
+ NSTemporaryExceptionAllowsInsecureHTTPLoads
+
+
++ zulipdev.com
++
++ NSTemporaryExceptionAllowsInsecureHTTPLoads
++
++
+
+
+```
+
+See
+[discussion](https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/Apple.20ATS.20for.20debug/near/883318)
+for more convenient solutions if we find we have to allow this
+regularly.
diff --git a/docs/howto/libdefs.md b/docs/howto/libdefs.md
index 5faa90da019..871d0c8e924 100644
--- a/docs/howto/libdefs.md
+++ b/docs/howto/libdefs.md
@@ -152,3 +152,43 @@ Flow and FlowTyped about not being able to import third-party types
into one's own libdefs that haven't been resolved. [9]
[9]: https://github.com/zulip/zulip-mobile/issues/3458#issuecomment-639859987
+
+## Expo packages (made available through Unimodules)
+
+We're starting to see a pattern developing with these, e.g.:
+
+- `expo-apple-authentication`
+- `expo-screen-orientation`
+
+Namely:
+
+1. See what `node_modules/expo-name-of-package/build/index.d.ts`
+ depends on; it's probably at least `'./NameOfPackage'` and
+ `'./NameOfPackage.types'`.
+
+ Assuming so, make a `declare module expo-name-of-package` block and
+ have it do what that `index.d.ts` does, maybe
+
+ ```javascript
+ declare module 'expo-name-of-package' {
+ declare export * from 'expo-name-of-package/build/NameOfPackage'
+ declare export * from 'expo-name-of-package/build/NameOfPackage.types'
+ }
+ ```
+
+2. Run `node_modules/expo-name-of-package/build/NameOfPackage.d.ts`
+ through Flowgen and paste the output into a
+ `declare module 'expo-name-of-package/build/NameOfPackage'`
+ block.
+2. Run `node_modules/expo-name-of-package/build/PackageName.types'`
+ through Flowgen and paste the output into a
+ `declare module 'expo-screen-orientation/build/ScreenOrientation.types'`
+ block.
+3. Make any necessary syntactic fixes based on error messages (in
+ particular, replacing `export` with `declare export` everywhere may
+ be necessary) or adjustments to imports. You may only import from
+ something that's been declared in that same file, with
+ `declare export` [1] [2].
+
+[1]: https://github.com/flow-typed/flow-typed/blob/master/CONTRIBUTING.md#dont-import-types-from-other-libdefs
+[2]: See discussion around https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/libdef.3A.20react-native-webview/near/896713.
diff --git a/flow-typed/expo-apple-authentication_vx.x.x.js b/flow-typed/expo-apple-authentication_vx.x.x.js
new file mode 100644
index 00000000000..b72b1cc72ba
--- /dev/null
+++ b/flow-typed/expo-apple-authentication_vx.x.x.js
@@ -0,0 +1,299 @@
+// Assembled with help from Flowgen v1.10.0.
+//
+// The modules 'expo-apple-authentication/build/AppleAuthentication',
+// 'expo-apple-authentication/build/AppleAuthenticationButton', and
+// 'expo-apple-authentication/build/AppleAuthentication.types' are the
+// result of passing those files in node_modules through Flowgen and
+// doing some minor syntactic tweaks.
+
+declare module 'expo-apple-authentication/build/AppleAuthentication' {
+ import type {
+ AppleAuthenticationSignInOptions,
+ AppleAuthenticationRefreshOptions,
+ AppleAuthenticationSignOutOptions,
+ AppleAuthenticationCredential,
+ AppleAuthenticationRevokeListener,
+ } from 'expo-apple-authentication/build/AppleAuthentication.types';
+ import typeof { AppleAuthenticationCredentialState } from 'expo-apple-authentication/build/AppleAuthentication.types';
+
+ declare type Subscription = {
+ remove: () => void,
+ };
+
+ declare export function isAvailableAsync(): Promise;
+ declare export function signInAsync(
+ options?: AppleAuthenticationSignInOptions,
+ ): Promise;
+ declare export function refreshAsync(
+ options: AppleAuthenticationRefreshOptions,
+ ): Promise;
+ declare export function signOutAsync(
+ options: AppleAuthenticationSignOutOptions,
+ ): Promise;
+ declare export function getCredentialStateAsync(
+ user: string,
+ ): Promise;
+ declare export function addRevokeListener(
+ listener: AppleAuthenticationRevokeListener,
+ ): Subscription;
+}
+
+declare module 'expo-apple-authentication/build/AppleAuthentication.types' {
+ declare export type AppleAuthenticationButtonProps = {
+ onPress: () => void | Promise,
+ buttonType: $Values,
+ buttonStyle: $Values,
+ cornerRadius?: number,
+ style?: mixed,
+ ...
+ };
+ /**
+ * The options you can supply when making a call to `AppleAuthentication.signInAsync()`. None of
+ * these options are required.
+ * @see [Apple
+ * Documentation](https://developer.apple.com/documentation/authenticationservices/asauthorizationopenidrequest)
+ * for more details.
+ */
+ declare export type AppleAuthenticationSignInOptions = {
+ /**
+ * The scope of personal information to which your app is requesting access. The user can choose
+ * to deny your app access to any scope at the time of logging in.
+ * @defaults `[]` (no scopes).
+ */
+ requestedScopes?: $Values[],
+
+ /**
+ * Data that's returned to you unmodified in the corresponding credential after a successful
+ * authentication. Used to verify that the response was from the request you made. Can be used to
+ * avoid replay attacks.
+ */
+ state?: string,
+
+ /**
+ * Data that is used to verify the uniqueness of a response and prevent replay attacks.
+ */
+ nonce?: string,
+ ...
+ };
+ /**
+ * The options you can supply when making a call to `AppleAuthentication.refreshAsync()`. You must
+ * include the ID string of the user whose credentials you'd like to refresh.
+ * @see [Apple
+ * Documentation](https://developer.apple.com/documentation/authenticationservices/asauthorizationopenidrequest)
+ * for more details.
+ */
+ declare export type AppleAuthenticationRefreshOptions = {
+ user: string,
+
+ /**
+ * The scope of personal information to which your app is requesting access. The user can choose
+ * to deny your app access to any scope at the time of refreshing.
+ * @defaults `[]` (no scopes).
+ */
+ requestedScopes?: $Values[],
+
+ /**
+ * Data that's returned to you unmodified in the corresponding credential after a successful
+ * authentication. Used to verify that the response was from the request you made. Can be used to
+ * avoid replay attacks.
+ */
+ state?: string,
+ ...
+ };
+ /**
+ * The options you can supply when making a call to `AppleAuthentication.signOutAsync()`. You must
+ * include the ID string of the user to sign out.
+ * @see [Apple
+ * Documentation](https://developer.apple.com/documentation/authenticationservices/asauthorizationopenidrequest)
+ * for more details.
+ */
+ declare export type AppleAuthenticationSignOutOptions = {
+ user: string,
+
+ /**
+ * Data that's returned to you unmodified in the corresponding credential after a successful
+ * authentication. Used to verify that the response was from the request you made. Can be used to
+ * avoid replay attacks.
+ */
+ state?: string,
+ ...
+ };
+ /**
+ * The user credentials returned from a successful call to `AppleAuthentication.signInAsync()`,
+ * `AppleAuthentication.refreshAsync()`, or `AppleAuthentication.signOutAsync()`.
+ * @see [Apple
+ * Documentation](https://developer.apple.com/documentation/authenticationservices/asauthorizationappleidcredential)
+ * for more details.
+ */
+ declare export type AppleAuthenticationCredential = {
+ /**
+ * An identifier associated with the authenticated user. You can use this to check if the user is
+ * still authenticated later. This is stable and can be shared across apps released under the same
+ * development team. The same user will have a different identifier for apps released by other
+ * developers.
+ */
+ user: string,
+
+ /**
+ * An arbitrary string that your app provided as `state` in the request that generated the
+ * credential. Used to verify that the response was from the request you made. Can be used to
+ * avoid replay attacks.
+ */
+ state: string | null,
+
+ /**
+ * The user's name. May be `null` or contain `null` values if you didn't request the `FULL_NAME`
+ * scope, if the user denied access, or if this is not the first time the user has signed into
+ * your app.
+ */
+ fullName: AppleAuthenticationFullName | null,
+
+ /**
+ * The user's email address. Might not be present if you didn't request the `EMAIL` scope. May
+ * also be null if this is not the first time the user has signed into your app. If the user chose
+ * to withhold their email address, this field will instead contain an obscured email address with
+ * an Apple domain.
+ */
+ email: string | null,
+
+ /**
+ * A value that indicates whether the user appears to the system to be a real person.
+ */
+ realUserStatus: $Values,
+
+ /**
+ * A JSON Web Token (JWT) that securely communicates information about the user to your app.
+ */
+ identityToken: string,
+
+ /**
+ * A short-lived session token used by your app for proof of authorization when interacting with
+ * the app's server counterpart. Unlike `user`, this is ephemeral and will change each session.
+ */
+ authorizationCode: string,
+ ...
+ };
+ /**
+ * An object representing the tokenized portions of the user's full name.
+ */
+ declare export type AppleAuthenticationFullName = {
+ namePrefix: string | null,
+ givenName: string | null,
+ middleName: string | null,
+ familyName: string | null,
+ nameSuffix: string | null,
+ nickname: string | null,
+ ...
+ };
+ declare export type AppleAuthenticationRevokeListener = () => void;
+ /**
+ * Scopes you can request when calling `AppleAuthentication.signInAsync()` or
+ * `AppleAuthentication.refreshAsync()`.
+ * @note Note that it is possible that you will not be granted all of the scopes which you request.
+ * You will still need to handle null values for any fields you request.
+ * @see [Apple
+ * Documentation](https://developer.apple.com/documentation/authenticationservices/asauthorizationscope)
+ * for more details.
+ */
+
+ declare export var AppleAuthenticationScope: {|
+ +FULL_NAME: 0, // 0
+ +EMAIL: 1, // 1
+ |};
+
+ declare export var AppleAuthenticationOperation: {|
+ +IMPLICIT: 0, // 0
+ +LOGIN: 1, // 1
+ +REFRESH: 2, // 2
+ +LOGOUT: 3, // 3
+ |};
+
+ /**
+ * The state of the credential when checked with `AppleAuthentication.getCredentialStateAsync()`.
+ * @see [Apple
+ * Documentation](https://developer.apple.com/documentation/authenticationservices/asauthorizationappleidprovidercredentialstate)
+ * for more details.
+ */
+
+ declare export var AppleAuthenticationCredentialState: {|
+ +REVOKED: 0, // 0
+ +AUTHORIZED: 1, // 1
+ +NOT_FOUND: 2, // 2
+ +TRANSFERRED: 3, // 3
+ |};
+
+ /**
+ * A value that indicates whether the user appears to be a real person. You get this in the
+ * realUserStatus property of a `Credential` object. It can be used as one metric to help prevent
+ * fraud.
+ * @see [Apple
+ * Documentation](https://developer.apple.com/documentation/authenticationservices/asuserdetectionstatus)
+ * for more details.
+ */
+
+ declare export var AppleAuthenticationUserDetectionStatus: {|
+ +UNSUPPORTED: 0, // 0
+ +UNKNOWN: 1, // 1
+ +LIKELY_REAL: 2, // 2
+ |};
+
+ /**
+ * Controls the predefined text shown on the authentication button.
+ */
+
+ declare export var AppleAuthenticationButtonType: {|
+ +SIGN_IN: 0, // 0
+ +CONTINUE: 1, // 1
+ |};
+
+ /**
+ * Controls the predefined style of the authenticating button.
+ */
+
+ declare export var AppleAuthenticationButtonStyle: {|
+ +WHITE: 0, // 0
+ +WHITE_OUTLINE: 1, // 1
+ +BLACK: 2, // 2
+ |};
+}
+
+declare module 'expo-apple-authentication/build/AppleAuthenticationButton' {
+ import type { StatelessFunctionalComponent } from 'react';
+ /* eslint-disable-next-line */
+ import type { AppleAuthenticationButtonProps } from 'expo-apple-authentication/build/AppleAuthentication.types';
+
+ /**
+ * This component displays the proprietary "Sign In with Apple" / "Continue with Apple" button on
+ * your screen. The App Store Guidelines require you to use this component to start the sign in
+ * process instead of a custom button. You can customize the design of the button using the
+ * properties. You should start the sign in process when the `onPress` property is called.
+ *
+ * You should only attempt to render this if `AppleAuthentication.isAvailableAsync()` resolves to
+ * true. This component will render nothing if it is not available and you will get a warning if
+ * `__DEV__ === true`.
+ *
+ * The properties of this component extend from `View`; however, you should not attempt to set
+ * `backgroundColor` or `borderRadius` with the `style` property. This will not work and is against
+ * the App Store Guidelines. Instead, you should use the `buttonStyle` property to choose one of the
+ * predefined color styles and the `cornerRadius` property to change the border radius of the
+ * button.
+ * @see [Apple
+ * Documentation](https://developer.apple.com/documentation/authenticationservices/asauthorizationappleidbutton)
+ * for more details.
+ */
+ declare type AppleAuthenticationButton = StatelessFunctionalComponent;
+ declare export default AppleAuthenticationButton;
+}
+
+/*
+ * Flowtype definitions for AppleAuthenticationButton
+ * Generated by Flowgen from a Typescript Definition
+ * Flowgen v1.10.0
+ */
+declare module 'expo-apple-authentication' {
+ declare export * from 'expo-apple-authentication/build/AppleAuthentication'
+ declare export * from 'expo-apple-authentication/build/AppleAuthentication.types'
+ declare export {
+ default as AppleAuthenticationButton,
+ } from 'expo-apple-authentication/build/AppleAuthenticationButton';
+}
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index a0f456b9368..46eb62229d5 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1,6 +1,8 @@
PODS:
- boost-for-react-native (1.63.0)
- DoubleConversion (1.1.6)
+ - EXAppleAuthentication (2.1.1):
+ - UMCore
- EXApplication (2.1.1):
- UMCore
- EXAppLoaderProvider (7.0.0)
@@ -154,6 +156,7 @@ PODS:
DEPENDENCIES:
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
+ - EXAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`)
- EXApplication (from `../node_modules/expo-application/ios`)
- EXAppLoaderProvider (from `../node_modules/expo-app-loader-provider/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
@@ -218,6 +221,9 @@ SPEC REPOS:
EXTERNAL SOURCES:
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
+ EXAppleAuthentication:
+ :path: !ruby/object:Pathname
+ path: "../node_modules/expo-apple-authentication/ios"
EXApplication:
:path: !ruby/object:Pathname
path: "../node_modules/expo-application/ios"
@@ -348,6 +354,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost-for-react-native: 39c7adb57c4e60d6c5479dd8623128eb5b3f0f2c
DoubleConversion: 5805e889d232975c086db112ece9ed034df7a0b2
+ EXAppleAuthentication: 046c76335343eaa97f6ed8d35a9cf493a2c4d351
EXApplication: 7cf81de6fafccff42f5d1caa5c24a159db6b9437
EXAppLoaderProvider: 5d348813a9cf09b03bbe5b8b55437bc1bfbddbd1
EXConstants: 857aa7b1c84e2878f8402d712061860bca16a697
diff --git a/ios/ZulipMobile/ZulipMobile.entitlements b/ios/ZulipMobile/ZulipMobile.entitlements
index 903def2af53..80b5221de76 100644
--- a/ios/ZulipMobile/ZulipMobile.entitlements
+++ b/ios/ZulipMobile/ZulipMobile.entitlements
@@ -4,5 +4,9 @@
aps-environment
development
+ com.apple.developer.applesignin
+
+ Default
+
diff --git a/jest.config.js b/jest.config.js
index 22810f3ba57..7b16fdf9eb1 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -3,6 +3,7 @@
//
// These will be used as regexp fragments.
const transformModulesWhitelist = [
+ 'expo-apple-authentication',
'react-native',
// @rnc/async-storage itself is precompiled, but its mock-helper is not
'@react-native-community/async-storage',
@@ -10,6 +11,7 @@ const transformModulesWhitelist = [
'@expo/react-native-action-sheet',
'react-navigation',
'@sentry/react-native',
+ '@unimodules/',
'@zulip/',
];
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index 86e85e62435..97f1a29db41 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -36,3 +36,10 @@ jest.mock('react-native-device-info', () => ({
getSystemName: jest.fn().mockReturnValue('ios'),
getSystemVersion: jest.fn().mockReturnValue('13.3.1'),
}));
+
+jest.mock('expo-apple-authentication', () => ({
+ AppleAuthenticationButton: jest.fn(),
+ isAvailableAsync: jest.fn(),
+ signInAsync: jest.fn(),
+ // etc. (incomplete)
+}));
diff --git a/package.json b/package.json
index 83721483d11..e8e852bbf9c 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"immutable": "^4.0.0-rc.12",
"json-stringify-safe": "^5.0.1",
"katex": "^0.11.1",
+ "expo-apple-authentication": "^2.1.1",
"lodash.escape": "^4.0.1",
"lodash.isequal": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
diff --git a/src/common/Icons.js b/src/common/Icons.js
index 5097e6ab34f..6b609595327 100644
--- a/src/common/Icons.js
+++ b/src/common/Icons.js
@@ -63,6 +63,7 @@ export const IconStream = makeIcon(Feather, 'hash');
export const IconPin = makeIcon(SimpleLineIcons, 'pin');
export const IconPrivate = makeIcon(Feather, 'lock');
export const IconPrivateChat = makeIcon(Feather, 'mail');
+export const IconApple = makeIcon(IoniconsIcon, 'logo-apple');
export const IconGoogle = makeIcon(IoniconsIcon, 'logo-google');
export const IconGitHub = makeIcon(Feather, 'github');
export const IconWindows = makeIcon(IoniconsIcon, 'logo-windows');
diff --git a/src/common/ZulipButton.js b/src/common/ZulipButton.js
index f3bd729644d..45a76313c35 100644
--- a/src/common/ZulipButton.js
+++ b/src/common/ZulipButton.js
@@ -4,6 +4,7 @@ import { StyleSheet, Text, View, ActivityIndicator } from 'react-native';
import type { ViewStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet';
import TranslatedText from './TranslatedText';
+import type { LocalizableText } from '../types';
import type { SpecificIconType } from './Icons';
import { BRAND_COLOR } from '../styles';
import Touchable from './Touchable';
@@ -64,7 +65,7 @@ type Props = $ReadOnly<{|
progress: boolean,
disabled: boolean,
Icon?: SpecificIconType,
- text: string,
+ text: LocalizableText,
secondary: boolean,
onPress: () => void | Promise,
|}>;
diff --git a/src/config.js b/src/config.js
index 1a893b21223..ccc0610c6e9 100644
--- a/src/config.js
+++ b/src/config.js
@@ -11,6 +11,7 @@ type Config = {|
sentryKey: string | null,
enableErrorConsoleLogging: boolean,
serverDataOnStartup: string[],
+ appOwnDomains: string[],
|};
const config: Config = {
@@ -39,6 +40,7 @@ const config: Config = {
'update_message_flags',
'user_status',
],
+ appOwnDomains: ['zulip.com', 'zulipchat.com', 'chat.zulip.org'],
};
export default config;
diff --git a/src/start/AuthScreen.js b/src/start/AuthScreen.js
index 99accf9d42f..e9c84477548 100644
--- a/src/start/AuthScreen.js
+++ b/src/start/AuthScreen.js
@@ -1,26 +1,38 @@
/* @flow strict-local */
import React, { PureComponent } from 'react';
-import { Linking } from 'react-native';
+import { Linking, Platform } from 'react-native';
import type { NavigationScreenProp } from 'react-navigation';
import { URL as WhatwgURL } from 'react-native-url-polyfill';
+import type { AppleAuthenticationCredential } from 'expo-apple-authentication';
+import * as AppleAuthentication from 'expo-apple-authentication';
+import config from '../config';
import type {
AuthenticationMethods,
Dispatch,
ExternalAuthenticationMethod,
ApiResponseServerSettings,
} from '../types';
-import { IconPrivate, IconGoogle, IconGitHub, IconWindows, IconTerminal } from '../common/Icons';
+import {
+ IconApple,
+ IconPrivate,
+ IconGoogle,
+ IconGitHub,
+ IconWindows,
+ IconTerminal,
+} from '../common/Icons';
import type { SpecificIconType } from '../common/Icons';
import { connect } from '../react-redux';
import styles from '../styles';
import { Centerer, Screen, ZulipButton } from '../common';
import { getCurrentRealm } from '../selectors';
import RealmInfo from './RealmInfo';
-import { getFullUrl } from '../utils/url';
+import { getFullUrl, encodeParamsForUrl, tryParseUrl } from '../utils/url';
import * as webAuth from './webAuth';
import { loginSuccess, navigateToDev, navigateToPassword } from '../actions';
+import IosCompliantAppleAuthButton from './IosCompliantAppleAuthButton';
+import openLink from '../utils/openLink';
/**
* Describes a method for authenticating to the server.
@@ -100,6 +112,7 @@ const externalMethodIcons = new Map([
['google', IconGoogle],
['github', IconGitHub],
['azuread', IconWindows],
+ ['apple', IconApple],
]);
/** Exported for tests only. */
@@ -227,12 +240,68 @@ class AuthScreen extends PureComponent {
this.props.dispatch(navigateToPassword(serverSettings.require_email_format_usernames));
};
- handleAuth = (method: AuthenticationMethodDetails) => {
+ handleNativeAppleAuth = async () => {
+ const state = await webAuth.generateRandomToken();
+ const credential: AppleAuthenticationCredential = await AppleAuthentication.signInAsync({
+ state,
+ requestedScopes: [
+ AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
+ AppleAuthentication.AppleAuthenticationScope.EMAIL,
+ ],
+ });
+ if (credential.state !== state) {
+ throw new Error('`state` mismatch');
+ }
+
+ otp = await webAuth.generateOtp();
+
+ const params = encodeParamsForUrl({
+ mobile_flow_otp: otp,
+ native_flow: true,
+ id_token: credential.identityToken,
+ });
+
+ openLink(`${this.props.realm}/complete/apple/?${params}`);
+
+ // Currently, the rest is handled with the `zulip://` redirect,
+ // same as in the web flow.
+ //
+ // TODO: Maybe have an endpoint we can just send a request to,
+ // with `fetch`, and get the API key right away, without ever
+ // having to open the browser.
+ };
+
+ canUseNativeAppleFlow = async () => {
+ if (!(Platform.OS === 'ios' && (await AppleAuthentication.isAvailableAsync()))) {
+ return false;
+ }
+
+ const host = tryParseUrl(this.props.realm)?.host;
+ if (host === undefined) {
+ // `this.props.realm` invalid.
+ // TODO: Check this much sooner.
+ return false;
+ }
+
+ // The native flow for Apple auth assumes that the app and the server
+ // are operated by the same organization, so that for a user to
+ // entrust private information to either one is the same as entrusting
+ // it to the other. Check that this realm is on such a server.
+ //
+ // (For other realms, we'll simply fall back to the web flow, which
+ // handles things appropriately without relying on that assumption.)
+ return config.appOwnDomains.some(domain => host === domain || host.endsWith(`.${domain}`));
+ };
+
+ handleAuth = async (method: AuthenticationMethodDetails) => {
const { action } = method;
+
if (action === 'dev') {
this.handleDevAuth();
} else if (action === 'password') {
this.handlePassword();
+ } else if (method.name === 'apple' && (await this.canUseNativeAppleFlow())) {
+ this.handleNativeAppleAuth();
} else {
this.beginWebAuth(action.url);
}
@@ -251,16 +320,27 @@ class AuthScreen extends PureComponent {
{activeAuthentications(
serverSettings.authentication_methods,
serverSettings.external_authentication_methods,
- ).map(auth => (
- this.handleAuth(auth)}
- />
- ))}
+ ).map(auth =>
+ auth.name === 'apple' && Platform.OS === 'ios' ? (
+ this.handleAuth(auth)}
+ />
+ ) : (
+ this.handleAuth(auth)}
+ />
+ ),
+ )}
);
diff --git a/src/start/IosCompliantAppleAuthButton/Custom.js b/src/start/IosCompliantAppleAuthButton/Custom.js
new file mode 100644
index 00000000000..937ac48ae48
--- /dev/null
+++ b/src/start/IosCompliantAppleAuthButton/Custom.js
@@ -0,0 +1,80 @@
+/* @flow strict-local */
+import React, { PureComponent } from 'react';
+import { StyleSheet, Text, View, Image, TouchableWithoutFeedback } from 'react-native';
+import type { ViewStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet';
+
+import type { ThemeName } from '../../reduxTypes';
+import TranslatedText from '../../common/TranslatedText';
+import appleLogoBlackImg from '../../../static/img/apple-logo-black.png';
+import appleLogoWhiteImg from '../../../static/img/apple-logo-white.png';
+
+const styles = StyleSheet.create({
+ buttonContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: 44,
+ },
+ frame: {
+ height: 44,
+ justifyContent: 'center',
+ borderRadius: 22,
+ overflow: 'hidden',
+ },
+ nightFrame: {
+ backgroundColor: 'black',
+ },
+ dayFrame: {
+ backgroundColor: 'white',
+ borderWidth: 1.5,
+ borderColor: 'black',
+ },
+ text: {
+ fontSize: 16,
+ },
+ nightText: {
+ color: 'white',
+ },
+ dayText: {
+ color: 'black',
+ },
+});
+
+type Props = $ReadOnly<{|
+ style?: ViewStyleProp,
+ onPress: () => void | Promise,
+ theme: ThemeName,
+|}>;
+
+/**
+ * The custom "Sign in with Apple" button that follows the rules.
+ *
+ * Do not reuse this component; it is only meant to be rendered by the
+ * IosCompliantAppleAuthButton, which controls whether the custom
+ * button should be used.
+ */
+export default class Custom extends PureComponent {
+ render() {
+ const { style, onPress, theme } = this.props;
+ const logoSource = theme === 'default' ? appleLogoBlackImg : appleLogoWhiteImg;
+ const frameStyle = [
+ styles.frame,
+ theme === 'default' ? styles.dayFrame : styles.nightFrame,
+ style,
+ ];
+ const textStyle = [styles.text, theme === 'default' ? styles.dayText : styles.nightText];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/start/IosCompliantAppleAuthButton/index.js b/src/start/IosCompliantAppleAuthButton/index.js
new file mode 100644
index 00000000000..653028184e3
--- /dev/null
+++ b/src/start/IosCompliantAppleAuthButton/index.js
@@ -0,0 +1,75 @@
+/* @flow strict-local */
+import React, { PureComponent } from 'react';
+import { View } from 'react-native';
+import type { ViewStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet';
+import * as AppleAuthentication from 'expo-apple-authentication';
+import { connect } from '../../react-redux';
+
+import Custom from './Custom';
+import type { ThemeName } from '../../reduxTypes';
+import type { Dispatch } from '../../types';
+import { getSettings } from '../../selectors';
+
+type SelectorProps = $ReadOnly<{|
+ theme: ThemeName,
+|}>;
+
+type Props = $ReadOnly<{|
+ style?: ViewStyleProp,
+ onPress: () => void | Promise,
+
+ dispatch: Dispatch,
+ ...SelectorProps,
+|}>;
+
+type State = $ReadOnly<{|
+ isNativeButtonAvailable: boolean | void,
+|}>;
+
+/**
+ * A "Sign in with Apple" button (iOS only) that follows the rules.
+ *
+ * These official guidelines from Apple are at
+ * https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple/overview/buttons/.
+ *
+ * Not to be used on Android. There, we also offer "Sign in with
+ * Apple", but without marking it with a different style from the
+ * other buttons.
+ */
+class IosCompliantAppleAuthButton extends PureComponent {
+ state = {
+ isNativeButtonAvailable: undefined,
+ };
+
+ async componentDidMount() {
+ this.setState({ isNativeButtonAvailable: await AppleAuthentication.isAvailableAsync() });
+ }
+
+ render() {
+ const { style, onPress, theme } = this.props;
+ const { isNativeButtonAvailable } = this.state;
+ if (isNativeButtonAvailable === undefined) {
+ return ;
+ } else if (isNativeButtonAvailable) {
+ return (
+
+ );
+ } else {
+ return ;
+ }
+ }
+}
+
+export default connect(state => ({
+ theme: getSettings(state).theme,
+}))(IosCompliantAppleAuthButton);
diff --git a/src/start/webAuth.js b/src/start/webAuth.js
index 71a1adb160f..8e043a9f687 100644
--- a/src/start/webAuth.js
+++ b/src/start/webAuth.js
@@ -27,9 +27,7 @@ import { base64ToHex, hexToAscii, xorHexStrings } from '../utils/encoding';
https://chat.zulip.org/#narrow/stream/16-desktop/topic/desktop.20app.20OAuth/near/803919
*/
-// 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 () => {
+export const generateRandomToken = async (): Promise => {
if (Platform.OS === 'android') {
return new Promise((resolve, reject) => {
NativeModules.RNSecureRandom.randomBase64(32, (err, result) => {
@@ -45,6 +43,10 @@ export const generateOtp = async () => {
}
};
+// 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 (): Promise => generateRandomToken();
+
export const openBrowser = (url: string, otp: string) => {
openLink(`${url}?mobile_flow_otp=${otp}`);
};
diff --git a/src/utils/url.js b/src/utils/url.js
index b5164c229d5..d33873a871c 100644
--- a/src/utils/url.js
+++ b/src/utils/url.js
@@ -1,5 +1,6 @@
/* @flow strict-local */
import urlRegex from 'url-regex';
+import { URL as WhatwgURL } from 'react-native-url-polyfill';
import type { Auth } from '../types';
import { getAuthHeaders } from '../api/transport';
@@ -31,6 +32,15 @@ export const encodeParamsForUrl = (params: UrlParams): string =>
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value.toString())}`)
.join('&');
+/** Just like `new WhatwgURL`, but on error return undefined instead of throwing. */
+export const tryParseUrl = (url: string, base?: string | WhatwgURL): WhatwgURL | void => {
+ try {
+ return new WhatwgURL(url, base);
+ } catch (e) {
+ return undefined;
+ }
+};
+
/**
* Turn a relative or absolute URL into an absolute URL.
*
diff --git a/static/img/apple-logo-black.png b/static/img/apple-logo-black.png
new file mode 100644
index 00000000000..b099599b934
Binary files /dev/null and b/static/img/apple-logo-black.png differ
diff --git a/static/img/apple-logo-white.png b/static/img/apple-logo-white.png
new file mode 100644
index 00000000000..def873e9f7d
Binary files /dev/null and b/static/img/apple-logo-white.png differ
diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json
index caf775bb032..5bdfd7efd90 100644
--- a/static/translations/messages_en.json
+++ b/static/translations/messages_en.json
@@ -34,11 +34,7 @@
"Password": "Password",
"Why not start the conversation?": "Why not start the conversation?",
"Chat": "Chat",
- "Log in with Google": "Log in with Google",
- "Log in with GitHub": "Log in with GitHub",
- "Log in with GitLab": "Log in with GitLab",
- "Log in with password": "Log in with password",
- "Log in with dev account": "Log in with dev account",
+ "Sign in with {method}": "Sign in with {method}",
"Cannot connect to server": "Cannot connect to server",
"Wrong email or password. Try again.": "Wrong email or password. Try again.",
"Wrong username or password. Try again.": "Wrong username or password. Try again.",
diff --git a/yarn.lock b/yarn.lock
index 2af07960f69..52f138bd875 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3709,6 +3709,11 @@ expo-app-loader-provider@~7.0.0:
resolved "https://registry.yarnpkg.com/expo-app-loader-provider/-/expo-app-loader-provider-7.0.0.tgz#9bfff831a204d0a8896e0120bce2209c4304ef03"
integrity sha512-C+5zpZN2T7PCj7weLs/ZgAC+y9dvu0VdTXD00Jf9Wo7Pxu/lsLh6ljg9JL91c+2tYDzMEODPNmT+JOUIxAr5zQ==
+expo-apple-authentication@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/expo-apple-authentication/-/expo-apple-authentication-2.1.1.tgz#ecba053fb6ae688f6a83553a192403e968eef931"
+ integrity sha512-E3k0Poo53N3pXimRVYpmxXhF+0PutQWYlQpfNFzRzZX+TjsHVehrTKmtd1KOimFpIs+u39MhsDQIIoMas12/dw==
+
expo-application@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-2.1.1.tgz#7cd61ccc53d7a0c85c8aac64b5ea59b20a1cb803"