169 lines
4.8 KiB
TypeScript
169 lines
4.8 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import * as Keychain from 'react-native-keychain';
|
|
|
|
import type { AuthSession } from '../../types/app';
|
|
|
|
const BIOMETRIC_KEY = 'bankcbs.biometricEnabled';
|
|
const SESSION_SERVICE = 'com.bankcbs.mobile.session';
|
|
/** The number and org last signed in with, so sign-in can prefill them. */
|
|
const PROFILE_SERVICE = 'com.bankcbs.mobile.lastProfile';
|
|
/**
|
|
* The MPIN, held only when the customer opts into biometric sign-in. The API
|
|
* has no refresh token — a session can only be opened by encrypting the MPIN —
|
|
* so biometric sign-in is impossible without keeping it. It lives behind the
|
|
* device's own biometric prompt and never in AsyncStorage.
|
|
*/
|
|
const BIOMETRIC_MPIN_SERVICE = 'com.bankcbs.mobile.biometricMpin';
|
|
|
|
export interface LastProfile {
|
|
orgId: string;
|
|
mobileNumber: string;
|
|
fullName?: string;
|
|
}
|
|
|
|
/**
|
|
* The request interceptor needs the token on every single call, and a Keystore
|
|
* read costs tens of milliseconds on Android. Held in memory alongside the
|
|
* Keychain copy, which stays the durable one across launches.
|
|
*/
|
|
let cachedSession: AuthSession | null = null;
|
|
let sessionCacheLoaded = false;
|
|
|
|
export const secureStorage = {
|
|
async saveSession(session: AuthSession) {
|
|
await Keychain.setGenericPassword('session', JSON.stringify(session), {
|
|
service: SESSION_SERVICE,
|
|
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
|
});
|
|
|
|
cachedSession = session;
|
|
sessionCacheLoaded = true;
|
|
},
|
|
|
|
async getSession(): Promise<AuthSession | null> {
|
|
if (sessionCacheLoaded) {
|
|
return cachedSession;
|
|
}
|
|
|
|
const credentials = await Keychain.getGenericPassword({
|
|
service: SESSION_SERVICE,
|
|
});
|
|
|
|
if (!credentials) {
|
|
cachedSession = null;
|
|
sessionCacheLoaded = true;
|
|
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
cachedSession = JSON.parse(credentials.password) as AuthSession;
|
|
sessionCacheLoaded = true;
|
|
|
|
return cachedSession;
|
|
} catch {
|
|
// A session written by an older build of the app. Treat it as absent
|
|
// rather than crashing the bootstrap.
|
|
await this.clearSession();
|
|
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async getAccessToken() {
|
|
const session = await this.getSession();
|
|
return session?.sessionToken ?? null;
|
|
},
|
|
|
|
async clearSession() {
|
|
cachedSession = null;
|
|
sessionCacheLoaded = true;
|
|
|
|
await Keychain.resetGenericPassword({
|
|
service: SESSION_SERVICE,
|
|
});
|
|
},
|
|
|
|
async saveLastProfile(profile: LastProfile) {
|
|
await Keychain.setGenericPassword('lastProfile', JSON.stringify(profile), {
|
|
service: PROFILE_SERVICE,
|
|
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
|
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
|
});
|
|
},
|
|
|
|
async getLastProfile(): Promise<LastProfile | null> {
|
|
const credentials = await Keychain.getGenericPassword({
|
|
service: PROFILE_SERVICE,
|
|
});
|
|
|
|
if (!credentials) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(credentials.password) as LastProfile;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async clearLastProfile() {
|
|
await Keychain.resetGenericPassword({ service: PROFILE_SERVICE });
|
|
},
|
|
|
|
/**
|
|
* Stored under the platform's biometric access control, so reading it back
|
|
* raises the fingerprint / Face ID prompt rather than the app faking one.
|
|
*/
|
|
async saveBiometricMpin(mpin: string) {
|
|
await Keychain.setGenericPassword('mpin', mpin, {
|
|
service: BIOMETRIC_MPIN_SERVICE,
|
|
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
|
|
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
|
});
|
|
},
|
|
|
|
async getBiometricMpin(promptTitle: string): Promise<string | null> {
|
|
try {
|
|
const credentials = await Keychain.getGenericPassword({
|
|
service: BIOMETRIC_MPIN_SERVICE,
|
|
authenticationPrompt: { title: promptTitle },
|
|
});
|
|
|
|
return credentials ? credentials.password : null;
|
|
} catch {
|
|
// Cancelled prompt, or no enrolled biometric any more.
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async hasBiometricMpin() {
|
|
try {
|
|
const services = await Keychain.getAllGenericPasswordServices();
|
|
return services.includes(BIOMETRIC_MPIN_SERVICE);
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
async clearBiometricMpin() {
|
|
await Keychain.resetGenericPassword({ service: BIOMETRIC_MPIN_SERVICE });
|
|
},
|
|
|
|
async setBiometricEnabled(value: boolean) {
|
|
await AsyncStorage.setItem(BIOMETRIC_KEY, JSON.stringify(value));
|
|
|
|
if (!value) {
|
|
await this.clearBiometricMpin();
|
|
}
|
|
},
|
|
|
|
async getBiometricEnabled() {
|
|
const value = await AsyncStorage.getItem(BIOMETRIC_KEY);
|
|
return value ? JSON.parse(value) : false;
|
|
},
|
|
};
|