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"