From ac3ede7f5090905d4d88462d76afd0d96139c6f3 Mon Sep 17 00:00:00 2001 From: Lewis Barnes Date: Sat, 6 Sep 2025 14:31:33 +0100 Subject: [PATCH 1/8] Initial attempt at wrapping native error objects in an custom Error instance. --- src/KeychainError.ts | 42 +++++++++ src/index.ts | 202 ++++++++++++++++++++++++++++--------------- 2 files changed, 176 insertions(+), 68 deletions(-) create mode 100644 src/KeychainError.ts diff --git a/src/KeychainError.ts b/src/KeychainError.ts new file mode 100644 index 00000000..f7db7639 --- /dev/null +++ b/src/KeychainError.ts @@ -0,0 +1,42 @@ +import { ERROR_CODE } from './enums'; + +interface NativeErrorObject { + code: ERROR_CODE; + message: string; +} + +const isNativeErrorObject = (err: unknown): err is NativeErrorObject => { + if (!err || typeof err !== "object") { + return false; + } + + return "code" in err && "message" in err; +}; + +/** + * Custom error class for encapsulating the native error objects. + */ +class KeychainError extends Error { + readonly code: ERROR_CODE; + + constructor(message: string, code: ERROR_CODE) { + super(message); + + this.name = 'KeychainError'; + this.code = code; + } + + static parse(err: unknown) { + if (err instanceof Error) { + return err; + } + + if (isNativeErrorObject(err)) { + return new KeychainError(err.message, err.code); + } + + return new KeychainError('An unknown error occurred', ERROR_CODE.INTERNAL_ERROR); + } +} + +export default KeychainError; diff --git a/src/index.ts b/src/index.ts index b2133c79..2215f943 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import type { AuthenticationTypeOption, AccessControlOption, } from './types'; +import KeychainError from './KeychainError'; import { normalizeAuthPrompt } from './normalizeOptions'; const { RNKeychainManager } = NativeModules; @@ -37,16 +38,20 @@ const { RNKeychainManager } = NativeModules; * await Keychain.setGenericPassword('username', 'password'); * ``` */ -export function setGenericPassword( +export async function setGenericPassword( username: string, password: string, options?: SetOptions ): Promise { - return RNKeychainManager.setGenericPasswordForOptions( - normalizeAuthPrompt(options), - username, - password - ); + try { + return RNKeychainManager.setGenericPasswordForOptions( + normalizeAuthPrompt(options), + username, + password + ) + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -66,12 +71,16 @@ export function setGenericPassword( * } * ``` */ -export function getGenericPassword( +export async function getGenericPassword( options?: GetOptions ): Promise { - return RNKeychainManager.getGenericPasswordForOptions( - normalizeAuthPrompt(options) - ); + try { + return RNKeychainManager.getGenericPasswordForOptions( + normalizeAuthPrompt(options) + ); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -87,8 +96,12 @@ export function getGenericPassword( * console.log('Password exists:', hasPassword); * ``` */ -export function hasGenericPassword(options?: BaseOptions): Promise { - return RNKeychainManager.hasGenericPasswordForOptions(options); +export async function hasGenericPassword(options?: BaseOptions): Promise { + try { + return RNKeychainManager.hasGenericPasswordForOptions(options); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -104,8 +117,12 @@ export function hasGenericPassword(options?: BaseOptions): Promise { * console.log('Password reset successful:', success); * ``` */ -export function resetGenericPassword(options?: BaseOptions): Promise { - return RNKeychainManager.resetGenericPasswordForOptions(options); +export async function resetGenericPassword(options?: BaseOptions): Promise { + try { + return RNKeychainManager.resetGenericPasswordForOptions(options); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -119,10 +136,14 @@ export function resetGenericPassword(options?: BaseOptions): Promise { * console.log('Services:', services); * ``` */ -export function getAllGenericPasswordServices( +export async function getAllGenericPasswordServices( options?: GetAllOptions ): Promise { - return RNKeychainManager.getAllGenericPasswordServices(options); + try { + return RNKeychainManager.getAllGenericPasswordServices(options); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -138,10 +159,14 @@ export function getAllGenericPasswordServices( * console.log('Internet credentials exist:', hasCredentials); * ``` */ -export function hasInternetCredentials( +export async function hasInternetCredentials( options: string | BaseOptions ): Promise { - return RNKeychainManager.hasInternetCredentialsForOptions(options); + try { + return RNKeychainManager.hasInternetCredentialsForOptions(options); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -159,18 +184,22 @@ export function hasInternetCredentials( * await Keychain.setInternetCredentials('https://example.com', 'username', 'password'); * ``` */ -export function setInternetCredentials( +export async function setInternetCredentials( server: string, username: string, password: string, options?: SetOptions ): Promise { - return RNKeychainManager.setInternetCredentialsForServer( - server, - username, - password, - normalizeAuthPrompt(options) - ); + try { + return RNKeychainManager.setInternetCredentialsForServer( + server, + username, + password, + normalizeAuthPrompt(options) + ); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -191,14 +220,18 @@ export function setInternetCredentials( * } * ``` */ -export function getInternetCredentials( +export async function getInternetCredentials( server: string, options?: GetOptions ): Promise { - return RNKeychainManager.getInternetCredentialsForServer( - server, - normalizeAuthPrompt(options) - ); + try { + return RNKeychainManager.getInternetCredentialsForServer( + server, + normalizeAuthPrompt(options) + ); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -214,8 +247,12 @@ export function getInternetCredentials( * console.log('Credentials reset for server'); * ``` */ -export function resetInternetCredentials(options: BaseOptions): Promise { - return RNKeychainManager.resetInternetCredentialsForOptions(options); +export async function resetInternetCredentials(options: BaseOptions): Promise { + try { + return RNKeychainManager.resetInternetCredentialsForOptions(options); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -229,12 +266,16 @@ export function resetInternetCredentials(options: BaseOptions): Promise { * console.log('Supported Biometry Type:', biometryType); * ``` */ -export function getSupportedBiometryType(): Promise { - if (!RNKeychainManager.getSupportedBiometryType) { - return Promise.resolve(null); - } +export async function getSupportedBiometryType(): Promise { + try { + if (!RNKeychainManager.getSupportedBiometryType) { + return Promise.resolve(null); + } - return RNKeychainManager.getSupportedBiometryType(); + return RNKeychainManager.getSupportedBiometryType(); + } catch (err) { + throw KeychainError.parse(err); + } } /** @@ -254,17 +295,22 @@ export function getSupportedBiometryType(): Promise { * } * ``` */ -export function requestSharedWebCredentials(): Promise< +export async function requestSharedWebCredentials(): Promise< false | SharedWebCredentials > { - if (Platform.OS !== 'ios') { - return Promise.reject( - new Error( - `requestSharedWebCredentials() is not supported on ${Platform.OS} yet` - ) - ); + try { + if (Platform.OS !== 'ios') { + return Promise.reject( + new Error( + `requestSharedWebCredentials() is not supported on ${Platform.OS} yet` + ) + ); + } + + return RNKeychainManager.requestSharedWebCredentials(); + } catch (err) { + throw KeychainError.parse(err); } - return RNKeychainManager.requestSharedWebCredentials(); } /** @@ -284,23 +330,28 @@ export function requestSharedWebCredentials(): Promise< * console.log('Shared web credentials set'); * ``` */ -export function setSharedWebCredentials( +export async function setSharedWebCredentials( server: string, username: string, password?: string ): Promise { - if (Platform.OS !== 'ios') { - return Promise.reject( - new Error( - `setSharedWebCredentials() is not supported on ${Platform.OS} yet` - ) + try { + if (Platform.OS !== 'ios') { + return Promise.reject( + new Error( + `setSharedWebCredentials() is not supported on ${Platform.OS} yet` + ) + ); + } + + return RNKeychainManager.setSharedWebCredentialsForServer( + server, + username, + password ); + } catch (err) { + throw KeychainError.parse(err); } - return RNKeychainManager.setSharedWebCredentialsForServer( - server, - username, - password - ); } /** @@ -318,13 +369,18 @@ export function setSharedWebCredentials( * console.log('Can imply authentication:', canAuthenticate); * ``` */ -export function canImplyAuthentication( +export async function canImplyAuthentication( options?: AuthenticationTypeOption ): Promise { - if (!RNKeychainManager.canCheckAuthentication) { - return Promise.resolve(false); + try { + if (!RNKeychainManager.canCheckAuthentication) { + return Promise.resolve(false); + } + + return RNKeychainManager.canCheckAuthentication(options); + } catch (err) { + throw KeychainError.parse(err); } - return RNKeychainManager.canCheckAuthentication(options); } /** @@ -342,13 +398,18 @@ export function canImplyAuthentication( * console.log('Security Level:', securityLevel); * ``` */ -export function getSecurityLevel( +export async function getSecurityLevel( options?: AccessControlOption ): Promise { - if (!RNKeychainManager.getSecurityLevel) { - return Promise.resolve(null); + try { + if (!RNKeychainManager.getSecurityLevel) { + return Promise.resolve(null); + } + + return RNKeychainManager.getSecurityLevel(options); + } catch (err) { + throw KeychainError.parse(err); } - return RNKeychainManager.getSecurityLevel(options); } /** @@ -362,11 +423,16 @@ export function getSecurityLevel( * console.log('Passcode authentication available:', isAvailable); * ``` */ -export function isPasscodeAuthAvailable(): Promise { - if (!RNKeychainManager.isPasscodeAuthAvailable) { - return Promise.resolve(false); +export async function isPasscodeAuthAvailable(): Promise { + try { + if (!RNKeychainManager.isPasscodeAuthAvailable) { + return Promise.resolve(false); + } + + return RNKeychainManager.isPasscodeAuthAvailable(); + } catch (err) { + throw KeychainError.parse(err); } - return RNKeychainManager.isPasscodeAuthAvailable(); } export * from './enums'; From 6025e6a36dc744c0e3e6bbeccbbd84abb2978961 Mon Sep 17 00:00:00 2001 From: Lewis Barnes Date: Sat, 6 Sep 2025 16:33:10 +0100 Subject: [PATCH 2/8] Simplified parsing and added reference to root cause. --- src/KeychainError.ts | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/KeychainError.ts b/src/KeychainError.ts index f7db7639..aade7e0f 100644 --- a/src/KeychainError.ts +++ b/src/KeychainError.ts @@ -1,41 +1,28 @@ import { ERROR_CODE } from './enums'; - -interface NativeErrorObject { - code: ERROR_CODE; - message: string; -} - -const isNativeErrorObject = (err: unknown): err is NativeErrorObject => { - if (!err || typeof err !== "object") { - return false; - } - - return "code" in err && "message" in err; -}; - /** * Custom error class for encapsulating the native error objects. */ -class KeychainError extends Error { +export class KeychainError extends Error { readonly code: ERROR_CODE; + readonly cause: Error | null; - constructor(message: string, code: ERROR_CODE) { + constructor(message: string, code: ERROR_CODE, cause?: Error) { super(message); this.name = 'KeychainError'; this.code = code; + this.cause = cause ?? null; } static parse(err: unknown) { if (err instanceof Error) { - return err; - } + const code = + 'code' in err ? (err.code as ERROR_CODE) : ERROR_CODE.INTERNAL_ERROR; - if (isNativeErrorObject(err)) { - return new KeychainError(err.message, err.code); + return new KeychainError(err.message, code, err); } - return new KeychainError('An unknown error occurred', ERROR_CODE.INTERNAL_ERROR); + return err; } } From bcd32e64f7eff3c4d1102a99b946b2e980759a53 Mon Sep 17 00:00:00 2001 From: Lewis Barnes Date: Sat, 6 Sep 2025 16:33:51 +0100 Subject: [PATCH 3/8] Added exports. --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 2215f943..940f492d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -437,6 +437,7 @@ export async function isPasscodeAuthAvailable(): Promise { export * from './enums'; export * from './types'; +export * from './KeychainError'; /** @ignore */ export default { SECURITY_LEVEL, @@ -446,6 +447,7 @@ export default { BIOMETRY_TYPE, STORAGE_TYPE, ERROR_CODE, + KeychainError, getSecurityLevel, canImplyAuthentication, getSupportedBiometryType, From c46a598ebb77a6cfad424895d4f8e58cebf20039 Mon Sep 17 00:00:00 2001 From: Lewis Barnes Date: Sat, 6 Sep 2025 16:34:07 +0100 Subject: [PATCH 4/8] Fixed logic around methods. --- src/index.ts | 104 ++++++++++++++++++++++++++------------------------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/src/index.ts b/src/index.ts index 940f492d..3391e8df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,11 +44,11 @@ export async function setGenericPassword( options?: SetOptions ): Promise { try { - return RNKeychainManager.setGenericPasswordForOptions( + return await RNKeychainManager.setGenericPasswordForOptions( normalizeAuthPrompt(options), username, password - ) + ); } catch (err) { throw KeychainError.parse(err); } @@ -75,7 +75,7 @@ export async function getGenericPassword( options?: GetOptions ): Promise { try { - return RNKeychainManager.getGenericPasswordForOptions( + return await RNKeychainManager.getGenericPasswordForOptions( normalizeAuthPrompt(options) ); } catch (err) { @@ -96,9 +96,11 @@ export async function getGenericPassword( * console.log('Password exists:', hasPassword); * ``` */ -export async function hasGenericPassword(options?: BaseOptions): Promise { +export async function hasGenericPassword( + options?: BaseOptions +): Promise { try { - return RNKeychainManager.hasGenericPasswordForOptions(options); + return await RNKeychainManager.hasGenericPasswordForOptions(options); } catch (err) { throw KeychainError.parse(err); } @@ -117,9 +119,11 @@ export async function hasGenericPassword(options?: BaseOptions): Promise { +export async function resetGenericPassword( + options?: BaseOptions +): Promise { try { - return RNKeychainManager.resetGenericPasswordForOptions(options); + return await RNKeychainManager.resetGenericPasswordForOptions(options); } catch (err) { throw KeychainError.parse(err); } @@ -140,7 +144,7 @@ export async function getAllGenericPasswordServices( options?: GetAllOptions ): Promise { try { - return RNKeychainManager.getAllGenericPasswordServices(options); + return await RNKeychainManager.getAllGenericPasswordServices(options); } catch (err) { throw KeychainError.parse(err); } @@ -163,7 +167,7 @@ export async function hasInternetCredentials( options: string | BaseOptions ): Promise { try { - return RNKeychainManager.hasInternetCredentialsForOptions(options); + return await RNKeychainManager.hasInternetCredentialsForOptions(options); } catch (err) { throw KeychainError.parse(err); } @@ -191,7 +195,7 @@ export async function setInternetCredentials( options?: SetOptions ): Promise { try { - return RNKeychainManager.setInternetCredentialsForServer( + return await RNKeychainManager.setInternetCredentialsForServer( server, username, password, @@ -225,7 +229,7 @@ export async function getInternetCredentials( options?: GetOptions ): Promise { try { - return RNKeychainManager.getInternetCredentialsForServer( + return await RNKeychainManager.getInternetCredentialsForServer( server, normalizeAuthPrompt(options) ); @@ -247,9 +251,11 @@ export async function getInternetCredentials( * console.log('Credentials reset for server'); * ``` */ -export async function resetInternetCredentials(options: BaseOptions): Promise { +export async function resetInternetCredentials( + options: BaseOptions +): Promise { try { - return RNKeychainManager.resetInternetCredentialsForOptions(options); + return await RNKeychainManager.resetInternetCredentialsForOptions(options); } catch (err) { throw KeychainError.parse(err); } @@ -267,12 +273,12 @@ export async function resetInternetCredentials(options: BaseOptions): Promise { - try { - if (!RNKeychainManager.getSupportedBiometryType) { - return Promise.resolve(null); - } + if (!RNKeychainManager.getSupportedBiometryType) { + return null; + } - return RNKeychainManager.getSupportedBiometryType(); + try { + return await RNKeychainManager.getSupportedBiometryType(); } catch (err) { throw KeychainError.parse(err); } @@ -298,16 +304,14 @@ export async function getSupportedBiometryType(): Promise export async function requestSharedWebCredentials(): Promise< false | SharedWebCredentials > { - try { - if (Platform.OS !== 'ios') { - return Promise.reject( - new Error( - `requestSharedWebCredentials() is not supported on ${Platform.OS} yet` - ) - ); - } + if (Platform.OS !== 'ios') { + throw new Error( + `requestSharedWebCredentials() is not supported on ${Platform.OS} yet` + ); + } - return RNKeychainManager.requestSharedWebCredentials(); + try { + return await RNKeychainManager.requestSharedWebCredentials(); } catch (err) { throw KeychainError.parse(err); } @@ -335,16 +339,14 @@ export async function setSharedWebCredentials( username: string, password?: string ): Promise { - try { - if (Platform.OS !== 'ios') { - return Promise.reject( - new Error( - `setSharedWebCredentials() is not supported on ${Platform.OS} yet` - ) - ); - } + if (Platform.OS !== 'ios') { + throw new Error( + `setSharedWebCredentials() is not supported on ${Platform.OS} yet` + ); + } - return RNKeychainManager.setSharedWebCredentialsForServer( + try { + return await RNKeychainManager.setSharedWebCredentialsForServer( server, username, password @@ -372,12 +374,12 @@ export async function setSharedWebCredentials( export async function canImplyAuthentication( options?: AuthenticationTypeOption ): Promise { - try { - if (!RNKeychainManager.canCheckAuthentication) { - return Promise.resolve(false); - } + if (!RNKeychainManager.canCheckAuthentication) { + return false; + } - return RNKeychainManager.canCheckAuthentication(options); + try { + return await RNKeychainManager.canCheckAuthentication(options); } catch (err) { throw KeychainError.parse(err); } @@ -401,12 +403,12 @@ export async function canImplyAuthentication( export async function getSecurityLevel( options?: AccessControlOption ): Promise { - try { - if (!RNKeychainManager.getSecurityLevel) { - return Promise.resolve(null); - } + if (!RNKeychainManager.getSecurityLevel) { + return null; + } - return RNKeychainManager.getSecurityLevel(options); + try { + return await RNKeychainManager.getSecurityLevel(options); } catch (err) { throw KeychainError.parse(err); } @@ -424,12 +426,12 @@ export async function getSecurityLevel( * ``` */ export async function isPasscodeAuthAvailable(): Promise { - try { - if (!RNKeychainManager.isPasscodeAuthAvailable) { - return Promise.resolve(false); - } + if (!RNKeychainManager.isPasscodeAuthAvailable) { + return false; + } - return RNKeychainManager.isPasscodeAuthAvailable(); + try { + return await RNKeychainManager.isPasscodeAuthAvailable(); } catch (err) { throw KeychainError.parse(err); } From 3cd1b7c98ad401f3f854243b510054ff2b43f602 Mon Sep 17 00:00:00 2001 From: Lewis Barnes Date: Sat, 6 Sep 2025 16:35:12 +0100 Subject: [PATCH 5/8] Minor refactor. --- src/KeychainError.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/KeychainError.ts b/src/KeychainError.ts index aade7e0f..35f428a7 100644 --- a/src/KeychainError.ts +++ b/src/KeychainError.ts @@ -1,4 +1,5 @@ import { ERROR_CODE } from './enums'; + /** * Custom error class for encapsulating the native error objects. */ From 7bb7d0c255bc1e38b3ecf36d112e3625d5865108 Mon Sep 17 00:00:00 2001 From: Lewis Barnes Date: Sat, 6 Sep 2025 17:11:00 +0100 Subject: [PATCH 6/8] Removed specification of simulator. --- KeychainExample/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KeychainExample/package.json b/KeychainExample/package.json index 2b6b3743..5ed49844 100644 --- a/KeychainExample/package.json +++ b/KeychainExample/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "android": "react-native run-android", - "ios": "react-native run-ios --simulator 'iPhone 15 Pro'", + "ios": "react-native run-ios", "build:android": "npm run mkdist && react-native bundle --entry-file index.js --platform android --dev false --bundle-output dist/main.android.jsbundle --assets-dest dist/res", "build:ios": "npm run mkdist && react-native bundle --entry-file index.js --platform ios --dev false --bundle-output dist/main.ios.jsbundle --assets-dest dist/assets", "mkdist": "node -e \"require('node:fs').mkdirSync('dist', { recursive: true, mode: 0o755 })\"", From b1668839bf683fef0a1d4a9530632f2b3f218a60 Mon Sep 17 00:00:00 2001 From: Lewis Barnes Date: Sat, 6 Sep 2025 17:11:31 +0100 Subject: [PATCH 7/8] Updated device type. --- KeychainExample/.detoxrc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KeychainExample/.detoxrc.js b/KeychainExample/.detoxrc.js index 9d6b24c1..7172e5dc 100644 --- a/KeychainExample/.detoxrc.js +++ b/KeychainExample/.detoxrc.js @@ -43,7 +43,7 @@ module.exports = { type: 'ios.simulator', headless: Boolean(process.env.CI), device: { - type: 'iPhone 15 Pro', + type: 'iPhone 16 Pro', }, }, attached: { From 0cf9df38c7266f8863d61fcf75d51ee397cdff30 Mon Sep 17 00:00:00 2001 From: Lewis Barnes Date: Sat, 6 Sep 2025 18:15:43 +0100 Subject: [PATCH 8/8] Added a property to state whether the operation can be retried. --- src/KeychainError.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/KeychainError.ts b/src/KeychainError.ts index 35f428a7..6a36271a 100644 --- a/src/KeychainError.ts +++ b/src/KeychainError.ts @@ -1,10 +1,17 @@ import { ERROR_CODE } from './enums'; +const RETRYABLE_ERROR_CODES = [ + ERROR_CODE.BIOMETRIC_TIMEOUT, + ERROR_CODE.BIOMETRIC_LOCKOUT, + ERROR_CODE.BIOMETRIC_TEMPORARILY_UNAVAILABLE, +]; + /** * Custom error class for encapsulating the native error objects. */ export class KeychainError extends Error { readonly code: ERROR_CODE; + readonly retryable: boolean; readonly cause: Error | null; constructor(message: string, code: ERROR_CODE, cause?: Error) { @@ -12,6 +19,7 @@ export class KeychainError extends Error { this.name = 'KeychainError'; this.code = code; + this.retryable = RETRYABLE_ERROR_CODES.includes(code); this.cause = cause ?? null; }