Skip to content

Commit

Permalink
auth: Handle "Sign in with Apple".
Browse files Browse the repository at this point in the history
From our perspective, this works just like any other social auth in
many cases. It's set up to conform to our protocol of giving a
redirect to a URL with the `zulip://` scheme containing the API key.

These cases (where the normal protocol is used) are

- Android
- iOS before version 13
- On servers not operated by the same people as the publishers of
  the mobile app (so for the official Zulip app, by Kandra Labs) [1]

In the remaining cases (Kandra-hosted realms on iOS 13+), we'll do
the native flow. This means we initiate the auth by using an iOS API
that natively handles querying for, e.g., the user's fingerprint,
face, or password, and gives us an `id_token`, which we send to the
server. Currently, we do this by opening the browser and awaiting
the same `zulip://` redirect, same as in the normal protocol.

As a followup, we may want to tweak this so it's not necessary to
ever open the browser in the native flow. We could just use `fetch`
and expect the API key in the response.

[1]: We don't want to send an `id_token` from the native flow to one
     of these realms; see discussion around
     https://chat.zulip.org/#narrow/stream/3-backend/topic/apple.20auth/near/918714.
  • Loading branch information
Chris Bobbe authored and gnprice committed Jun 30, 2020
1 parent f900521 commit b72e9a3
Show file tree
Hide file tree
Showing 2 changed files with 95 additions and 17 deletions.
1 change: 1 addition & 0 deletions src/common/Icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
111 changes: 94 additions & 17 deletions src/start/AuthScreen.js
Original file line number Diff line number Diff line change
@@ -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 } 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.
Expand Down Expand Up @@ -100,6 +112,7 @@ const externalMethodIcons = new Map([
['google', IconGoogle],
['github', IconGitHub],
['azuread', IconWindows],
['apple', IconApple],
]);

/** Exported for tests only. */
Expand Down Expand Up @@ -227,12 +240,68 @@ class AuthScreen extends PureComponent<Props> {
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())) {
let host: string | void;
try {
host = new WhatwgURL(this.props.realm).host;
} catch (e) {
// `this.props.realm` invalid.
// TODO: Check this much sooner.
}

// Check that the realm we're actually sending requests to,
// which is basically the URL the user entered on the first
// screen, is trusted by the official mobile app.
const isTrusted = config.appOwnDomains.some(
domain => host !== undefined && (host === domain || host.endsWith(`.${domain}`)),
);
return isTrusted;
}

return false;
};

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);
}
Expand All @@ -251,19 +320,27 @@ class AuthScreen extends PureComponent<Props> {
{activeAuthentications(
serverSettings.authentication_methods,
serverSettings.external_authentication_methods,
).map(auth => (
<ZulipButton
key={auth.name}
style={styles.halfMarginTop}
secondary
text={{
text: 'Sign in with {method}',
values: { method: auth.displayName },
}}
Icon={auth.Icon}
onPress={() => this.handleAuth(auth)}
/>
))}
).map(auth =>
auth.name === 'apple' && Platform.OS === 'ios' ? (
<IosCompliantAppleAuthButton
key={auth.name}
style={styles.halfMarginTop}
onPress={() => this.handleAuth(auth)}
/>
) : (
<ZulipButton
key={auth.name}
style={styles.halfMarginTop}
secondary
text={{
text: 'Sign in with {method}',
values: { method: auth.displayName },
}}
Icon={auth.Icon}
onPress={() => this.handleAuth(auth)}
/>
),
)}
</Centerer>
</Screen>
);
Expand Down

0 comments on commit b72e9a3

Please sign in to comment.