Skip to content

Commit cffcc5b

Browse files
Chris Bobbechrisbobbe
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 realms that are not hosted by Kandra [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 a server that's not associated with the official mobile app; see discussion around https://chat.zulip.org/#narrow/stream/3-backend/topic/apple.20auth/near/918714.
1 parent ab741b7 commit cffcc5b

File tree

2 files changed

+90
-14
lines changed

2 files changed

+90
-14
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: 89 additions & 14 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,66 @@ 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+
handleAuth = async (method: AuthenticationMethodDetails) => {
231275
const { action } = method;
276+
277+
let shouldUseNativeAppleFlow: boolean = false;
278+
if (
279+
Platform.OS === 'ios'
280+
&& method.name === 'apple'
281+
&& (await AppleAuthentication.isAvailableAsync())
282+
) {
283+
let host: string | void;
284+
try {
285+
// Check that the realm we're actually sending requests to,
286+
// which is basically the URL the user entered on the first
287+
// screen, is Kandra-hosted.
288+
host = new WhatwgURL(this.props.realm).host;
289+
} catch (e) {
290+
// `this.props.realm` invalid. TODO: Check this much sooner.
291+
}
292+
if (config.kandraHostedDomains.includes(host)) {
293+
shouldUseNativeAppleFlow = true;
294+
}
295+
}
296+
232297
if (action === 'dev') {
233298
this.handleDevAuth();
234299
} else if (action === 'password') {
235300
this.handlePassword();
301+
} else if (shouldUseNativeAppleFlow) {
302+
this.handleNativeAppleAuth();
236303
} else {
237304
this.beginWebAuth(action.url);
238305
}
@@ -251,16 +318,24 @@ class AuthScreen extends PureComponent<Props> {
251318
{activeAuthentications(
252319
serverSettings.authentication_methods,
253320
serverSettings.external_authentication_methods,
254-
).map(auth => (
255-
<ZulipButton
256-
key={auth.name}
257-
style={styles.halfMarginTop}
258-
secondary
259-
text={`Sign in with ${auth.displayName}`}
260-
Icon={auth.Icon}
261-
onPress={() => this.handleAuth(auth)}
262-
/>
263-
))}
321+
).map(auth =>
322+
auth.name === 'apple' && Platform.OS === 'ios' ? (
323+
<IosCompliantAppleAuthButton
324+
key={auth.name}
325+
style={styles.halfMarginTop}
326+
onPress={() => this.handleAuth(auth)}
327+
/>
328+
) : (
329+
<ZulipButton
330+
key={auth.name}
331+
style={styles.halfMarginTop}
332+
secondary
333+
text={`Sign in with ${auth.displayName}`}
334+
Icon={auth.Icon}
335+
onPress={() => this.handleAuth(auth)}
336+
/>
337+
),
338+
)}
264339
</Centerer>
265340
</Screen>
266341
);

0 commit comments

Comments
 (0)