mattermost-mobile-upstream/app/utils/security/authentication.ts
Elias Nahum f00701ec9a
On iOS, resuming after the 5-minute window showed no biometric prompt — (#10102)
* On iOS, resuming after the 5-minute window showed no biometric prompt —
just a blocking alert whose only button logged the user out. A failure to
evaluate device security was indistinguishable from a verdict that the
device is not secured. Android had the same class of bug, where
user-cancel codes led to logout.

Outcomes are now classified three ways, so only a genuine credential
rejection or a genuinely unsecured device acts destructively; cancels and
indeterminate results offer a retry. SecurityManager and ManagedApp share
the policy-free parts via app/utils/security/.

Also fixes a resume stacking a second prompt behind an open alert, a
backgroundSince bypass, and a latched flag that disabled the MDM PIN.

* feedback review

* gate app state

* fix tests
2026-09-02 15:03:13 +08:00

44 lines
1.9 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm, {AuthenticationOutcome} from '@mattermost/react-native-emm';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {DEVICE_SECURED_RETRY_DELAY, type AuthenticationSource} from './constants';
export type DeviceSecuredResult = 'secured' | 'notSecured' | 'interrupted';
// Not an instanceof check: the error may cross a module boundary. Anything unrecognised is
// indeterminate, so an unexpected failure never destroys a session or closes the app.
export const getAuthenticationOutcome = (error: unknown): AuthenticationOutcome => {
const outcome = (error as {outcome?: AuthenticationOutcome} | undefined)?.outcome;
return outcome ?? AuthenticationOutcome.Indeterminate;
};
/**
* Checks whether the device is secured, retrying once when the check cannot be performed.
*
* `interrupted` is not a verdict about the device: reporting it as unsecured would send the
* caller down a destructive path. The probe is silent, so retrying to absorb the iOS
* app-switcher animation costs the user nothing.
*/
export const probeDeviceSecured = async (source: AuthenticationSource): Promise<DeviceSecuredResult> => {
for (let attempt = 0; attempt < 2; attempt++) {
try {
// eslint-disable-next-line no-await-in-loop
const secured = await Emm.isDeviceSecured();
return secured ? 'secured' : 'notSecured';
} catch (error) {
logDebug(`${source}: Could not determine device security`, {attempt, reason: getFullErrorMessage(error)});
if (attempt === 0) {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, DEVICE_SECURED_RETRY_DELAY));
}
}
}
return 'interrupted';
};