Skip to content

Commit b72e9a3

Browse files
Chris Bobbegnprice
Chris Bobbe
authored andcommitted
auth: Handle "Sign in with Apple".
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.
1 parent f900521 commit b72e9a3

File tree

2 files changed

+95
-17
lines changed

2 files changed

+95
-17
lines changed

src/common/Icons.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const IconStream = makeIcon(Feather, 'hash');
6363
export const IconPin = makeIcon(SimpleLineIcons, 'pin');
6464
export const IconPrivate = makeIcon(Feather, 'lock');
6565
export const IconPrivateChat = makeIcon(Feather, 'mail');
66+
export const IconApple = makeIcon(IoniconsIcon, 'logo-apple');
6667
export const IconGoogle = makeIcon(IoniconsIcon, 'logo-google');
6768
export const IconGitHub = makeIcon(Feather, 'github');
6869
export const IconWindows = makeIcon(IoniconsIcon, 'logo-windows');

src/start/AuthScreen.js

Lines changed: 94 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,38 @@
11
/* @flow strict-local */
22

33
import React, { PureComponent } from 'react';
4-
import { Linking } from 'react-native';
4+
import { Linking, Platform } from 'react-native';
55
import type { NavigationScreenProp } from 'react-navigation';
66
import { URL as WhatwgURL } from 'react-native-url-polyfill';
7+
import type { AppleAuthenticationCredential } from 'expo-apple-authentication';
8+
import * as AppleAuthentication from 'expo-apple-authentication';
79

10+
import config from '../config';
811
import type {
912
AuthenticationMethods,
1013
Dispatch,
1114
ExternalAuthenticationMethod,
1215
ApiResponseServerSettings,
1316
} from '../types';
14-
import { IconPrivate, IconGoogle, IconGitHub, IconWindows, IconTerminal } from '../common/Icons';
17+
import {
18+
IconApple,
19+
IconPrivate,
20+
IconGoogle,
21+
IconGitHub,
22+
IconWindows,
23+
IconTerminal,
24+
} from '../common/Icons';
1525
import type { SpecificIconType } from '../common/Icons';
1626
import { connect } from '../react-redux';
1727
import styles from '../styles';
1828
import { Centerer, Screen, ZulipButton } from '../common';
1929
import { getCurrentRealm } from '../selectors';
2030
import RealmInfo from './RealmInfo';
21-
import { getFullUrl } from '../utils/url';
31+
import { getFullUrl, encodeParamsForUrl } from '../utils/url';
2232
import * as webAuth from './webAuth';
2333
import { loginSuccess, navigateToDev, navigateToPassword } from '../actions';
34+
import IosCompliantAppleAuthButton from './IosCompliantAppleAuthButton';
35+
import openLink from '../utils/openLink';
2436

2537
/**
2638
* Describes a method for authenticating to the server.
@@ -100,6 +112,7 @@ const externalMethodIcons = new Map([
100112
['google', IconGoogle],
101113
['github', IconGitHub],
102114
['azuread', IconWindows],
115+
['apple', IconApple],
103116
]);
104117

105118
/** Exported for tests only. */
@@ -227,12 +240,68 @@ class AuthScreen extends PureComponent<Props> {
227240
this.props.dispatch(navigateToPassword(serverSettings.require_email_format_usernames));
228241
};
229242

230-
handleAuth = (method: AuthenticationMethodDetails) => {
243+
handleNativeAppleAuth = async () => {
244+
const state = await webAuth.generateRandomToken();
245+
const credential: AppleAuthenticationCredential = await AppleAuthentication.signInAsync({
246+
state,
247+
requestedScopes: [
248+
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
249+
AppleAuthentication.AppleAuthenticationScope.EMAIL,
250+
],
251+
});
252+
if (credential.state !== state) {
253+
throw new Error('`state` mismatch');
254+
}
255+
256+
otp = await webAuth.generateOtp();
257+
258+
const params = encodeParamsForUrl({
259+
mobile_flow_otp: otp,
260+
native_flow: true,
261+
id_token: credential.identityToken,
262+
});
263+
264+
openLink(`${this.props.realm}/complete/apple/?${params}`);
265+
266+
// Currently, the rest is handled with the `zulip://` redirect,
267+
// same as in the web flow.
268+
//
269+
// TODO: Maybe have an endpoint we can just send a request to,
270+
// with `fetch`, and get the API key right away, without ever
271+
// having to open the browser.
272+
};
273+
274+
canUseNativeAppleFlow = async () => {
275+
if (Platform.OS === 'ios' && (await AppleAuthentication.isAvailableAsync())) {
276+
let host: string | void;
277+
try {
278+
host = new WhatwgURL(this.props.realm).host;
279+
} catch (e) {
280+
// `this.props.realm` invalid.
281+
// TODO: Check this much sooner.
282+
}
283+
284+
// Check that the realm we're actually sending requests to,
285+
// which is basically the URL the user entered on the first
286+
// screen, is trusted by the official mobile app.
287+
const isTrusted = config.appOwnDomains.some(
288+
domain => host !== undefined && (host === domain || host.endsWith(`.${domain}`)),
289+
);
290+
return isTrusted;
291+
}
292+
293+
return false;
294+
};
295+
296+
handleAuth = async (method: AuthenticationMethodDetails) => {
231297
const { action } = method;
298+
232299
if (action === 'dev') {
233300
this.handleDevAuth();
234301
} else if (action === 'password') {
235302
this.handlePassword();
303+
} else if (method.name === 'apple' && (await this.canUseNativeAppleFlow())) {
304+
this.handleNativeAppleAuth();
236305
} else {
237306
this.beginWebAuth(action.url);
238307
}
@@ -251,19 +320,27 @@ class AuthScreen extends PureComponent<Props> {
251320
{activeAuthentications(
252321
serverSettings.authentication_methods,
253322
serverSettings.external_authentication_methods,
254-
).map(auth => (
255-
<ZulipButton
256-
key={auth.name}
257-
style={styles.halfMarginTop}
258-
secondary
259-
text={{
260-
text: 'Sign in with {method}',
261-
values: { method: auth.displayName },
262-
}}
263-
Icon={auth.Icon}
264-
onPress={() => this.handleAuth(auth)}
265-
/>
266-
))}
323+
).map(auth =>
324+
auth.name === 'apple' && Platform.OS === 'ios' ? (
325+
<IosCompliantAppleAuthButton
326+
key={auth.name}
327+
style={styles.halfMarginTop}
328+
onPress={() => this.handleAuth(auth)}
329+
/>
330+
) : (
331+
<ZulipButton
332+
key={auth.name}
333+
style={styles.halfMarginTop}
334+
secondary
335+
text={{
336+
text: 'Sign in with {method}',
337+
values: { method: auth.displayName },
338+
}}
339+
Icon={auth.Icon}
340+
onPress={() => this.handleAuth(auth)}
341+
/>
342+
),
343+
)}
267344
</Centerer>
268345
</Screen>
269346
);

0 commit comments

Comments
 (0)