mattermost-mobile-upstream/app/utils/security/app_state.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

84 lines
2.5 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {type AppStateStatus} from 'react-native';
import {logDebug} from '@utils/log';
import {PROMPT_AUTH_AFTER, type AuthenticationSource} from './constants';
export type ResumeGateState = {
backgroundSince: number;
previousAppState?: AppStateStatus;
};
export type ResumeGateStrategy = {
/** Must be synchronous: it decides precedence before any await can interleave. */
isEnabled: () => boolean;
/** True while a prompt or alert is on screen. */
isGateOpen: () => boolean;
/** Blocks authentication without consuming the expiry window (e.g. jailbroken). */
shouldBlock?: () => Promise<boolean>;
/** Resolves true only when the user authenticated. */
authenticate: (authExpired: boolean) => Promise<boolean>;
/** Run the gate even when the window has not elapsed. */
promptWhenNotExpired: boolean;
source: AuthenticationSource;
};
/**
* Applies the resume/expiry rules to `state`, mutating it in place.
*
* `backgroundSince` is cleared only when the user is affirmatively let in, so a blocked or
* interrupted attempt cannot be bypassed by backgrounding the app again.
*/
export const handleAppStateResume = async (
appState: AppStateStatus,
state: ResumeGateState,
strategy: ResumeGateStrategy,
): Promise<void> => {
const isActive = appState === 'active';
const isBackground = appState === 'background';
if (isBackground) {
state.backgroundSince = Date.now();
}
const isResuming = isActive && state.previousAppState === 'background';
if (appState !== 'inactive') {
state.previousAppState = appState;
}
if (!isResuming || !strategy.isEnabled()) {
return;
}
// The open gate owns the decision; starting another would stack a second prompt
// behind the alert already on screen.
if (strategy.isGateOpen()) {
logDebug(`${strategy.source}: Resume ignored, authentication already in progress`);
return;
}
const authExpired = state.backgroundSince > 0 && (Date.now() - state.backgroundSince) >= PROMPT_AUTH_AFTER;
if (authExpired || strategy.promptWhenNotExpired) {
const blocked = await strategy.shouldBlock?.();
if (!blocked) {
const authenticated = await strategy.authenticate(authExpired);
if (authenticated) {
state.backgroundSince = 0;
}
}
} else {
state.backgroundSince = 0;
}
};