From 928cceda0a76e0d2344684c538fc4bed18247c6c Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Wed, 10 Jan 2024 14:14:13 +0800 Subject: [PATCH 01/15] Hide BiometricPrivateKeyOptions --- packages/authgear-react-native/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/authgear-react-native/src/types.ts b/packages/authgear-react-native/src/types.ts index 4081dea8..03051f61 100644 --- a/packages/authgear-react-native/src/types.ts +++ b/packages/authgear-react-native/src/types.ts @@ -255,7 +255,7 @@ export interface BiometricOptions { } /** - * @public + * @internal */ export interface BiometricPrivateKeyOptions extends BiometricOptions { kid: string; From 7ea66058c7e8b1d74e6421ec004b87241e4f40a2 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Wed, 10 Jan 2024 14:30:12 +0800 Subject: [PATCH 02/15] Do not export platform error consts --- packages/authgear-react-native/src/error.ts | 74 ++++++++++----------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/authgear-react-native/src/error.ts b/packages/authgear-react-native/src/error.ts index c73dd9e3..cf138ba8 100644 --- a/packages/authgear-react-native/src/error.ts +++ b/packages/authgear-react-native/src/error.ts @@ -11,50 +11,50 @@ import { AuthgearError, CancelError } from "@authgear/core"; // } // iOS LocalAuthentication -export const kLAErrorDomain = "com.apple.LocalAuthentication"; -// export const kLAErrorAuthenticationFailed = "-1"; -export const kLAErrorUserCancel = "-2"; -// export const kLAErrorUserFallback = "-3"; -// export const kLAErrorSystemCancel = "-4"; -export const kLAErrorPasscodeNotSet = "-5"; -// export const kLAErrorAppCancel = "-9"; -// export const kLAErrorInvalidContext = "-10"; -// export const kLAErrorWatchNotAvailable = "-11"; -// export const kLAErrorNotInteractive = "-1004"; -export const kLAErrorBiometryNotAvailable = "-6"; -export const kLAErrorBiometryNotEnrolled = "-7"; -export const kLAErrorBiometryLockout = "-8"; +const kLAErrorDomain = "com.apple.LocalAuthentication"; +// const kLAErrorAuthenticationFailed = "-1"; +const kLAErrorUserCancel = "-2"; +// const kLAErrorUserFallback = "-3"; +// const kLAErrorSystemCancel = "-4"; +const kLAErrorPasscodeNotSet = "-5"; +// const kLAErrorAppCancel = "-9"; +// const kLAErrorInvalidContext = "-10"; +// const kLAErrorWatchNotAvailable = "-11"; +// const kLAErrorNotInteractive = "-1004"; +const kLAErrorBiometryNotAvailable = "-6"; +const kLAErrorBiometryNotEnrolled = "-7"; +const kLAErrorBiometryLockout = "-8"; // iOS Keychain -export const NSOSStatusErrorDomain = "NSOSStatusErrorDomain"; -export const errSecUserCanceled = "-128"; -export const errSecAuthFailed = "-25293"; -export const errSecItemNotFound = "-25300"; +const NSOSStatusErrorDomain = "NSOSStatusErrorDomain"; +const errSecUserCanceled = "-128"; +// const errSecAuthFailed = "-25293"; +const errSecItemNotFound = "-25300"; // Android BiometricManager.canAuthenticate -export const BIOMETRIC_ERROR_HW_UNAVAILABLE = "BIOMETRIC_ERROR_HW_UNAVAILABLE"; -export const BIOMETRIC_ERROR_NONE_ENROLLED = "BIOMETRIC_ERROR_NONE_ENROLLED"; -export const BIOMETRIC_ERROR_NO_HARDWARE = "BIOMETRIC_ERROR_NO_HARDWARE"; -export const BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED = +const BIOMETRIC_ERROR_HW_UNAVAILABLE = "BIOMETRIC_ERROR_HW_UNAVAILABLE"; +const BIOMETRIC_ERROR_NONE_ENROLLED = "BIOMETRIC_ERROR_NONE_ENROLLED"; +const BIOMETRIC_ERROR_NO_HARDWARE = "BIOMETRIC_ERROR_NO_HARDWARE"; +const BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED = "BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED"; -export const BIOMETRIC_ERROR_UNSUPPORTED = "BIOMETRIC_ERROR_UNSUPPORTED"; -// export const BIOMETRIC_STATUS_UNKNOWN = "BIOMETRIC_STATUS_UNKNOWN"; +const BIOMETRIC_ERROR_UNSUPPORTED = "BIOMETRIC_ERROR_UNSUPPORTED"; +// const BIOMETRIC_STATUS_UNKNOWN = "BIOMETRIC_STATUS_UNKNOWN"; // Android BiometricPrompt -export const ERROR_CANCELED = "ERROR_CANCELED"; -export const ERROR_HW_NOT_PRESENT = "ERROR_HW_NOT_PRESENT"; -export const ERROR_HW_UNAVAILABLE = "ERROR_HW_UNAVAILABLE"; -export const ERROR_LOCKOUT = "ERROR_LOCKOUT"; -export const ERROR_LOCKOUT_PERMANENT = "ERROR_LOCKOUT_PERMANENT"; -export const ERROR_NEGATIVE_BUTTON = "ERROR_NEGATIVE_BUTTON"; -export const ERROR_NO_BIOMETRICS = "ERROR_NO_BIOMETRICS"; -export const ERROR_NO_DEVICE_CREDENTIAL = "ERROR_NO_DEVICE_CREDENTIAL"; -// export const ERROR_NO_SPACE = "ERROR_NO_SPACE"; -export const ERROR_SECURITY_UPDATE_REQUIRED = "ERROR_SECURITY_UPDATE_REQUIRED"; -// export const ERROR_TIMEOUT = "ERROR_TIMEOUT"; -// export const ERROR_UNABLE_TO_PROCESS = "ERROR_UNABLE_TO_PROCESS"; -export const ERROR_USER_CANCELED = "ERROR_USER_CANCELED"; -// export const ERROR_VENDOR = "ERROR_VENDOR"; +const ERROR_CANCELED = "ERROR_CANCELED"; +const ERROR_HW_NOT_PRESENT = "ERROR_HW_NOT_PRESENT"; +const ERROR_HW_UNAVAILABLE = "ERROR_HW_UNAVAILABLE"; +const ERROR_LOCKOUT = "ERROR_LOCKOUT"; +const ERROR_LOCKOUT_PERMANENT = "ERROR_LOCKOUT_PERMANENT"; +const ERROR_NEGATIVE_BUTTON = "ERROR_NEGATIVE_BUTTON"; +const ERROR_NO_BIOMETRICS = "ERROR_NO_BIOMETRICS"; +const ERROR_NO_DEVICE_CREDENTIAL = "ERROR_NO_DEVICE_CREDENTIAL"; +// const ERROR_NO_SPACE = "ERROR_NO_SPACE"; +const ERROR_SECURITY_UPDATE_REQUIRED = "ERROR_SECURITY_UPDATE_REQUIRED"; +// const ERROR_TIMEOUT = "ERROR_TIMEOUT"; +// const ERROR_UNABLE_TO_PROCESS = "ERROR_UNABLE_TO_PROCESS"; +const ERROR_USER_CANCELED = "ERROR_USER_CANCELED"; +// const ERROR_VENDOR = "ERROR_VENDOR"; export interface PlatformErrorIOS { code: string; From 06ff84be799726732c3e649c7f94c26e893a5bde Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Wed, 10 Jan 2024 14:53:28 +0800 Subject: [PATCH 03/15] Port JS code of biometric --- packages/authgear-capacitor/src/error.ts | 262 +++++++++++++++++++++- packages/authgear-capacitor/src/index.ts | 155 ++++++++++++- packages/authgear-capacitor/src/plugin.ts | 59 +++++ packages/authgear-capacitor/src/types.ts | 68 ++++++ 4 files changed, 525 insertions(+), 19 deletions(-) diff --git a/packages/authgear-capacitor/src/error.ts b/packages/authgear-capacitor/src/error.ts index a7fc1978..d9610fa8 100644 --- a/packages/authgear-capacitor/src/error.ts +++ b/packages/authgear-capacitor/src/error.ts @@ -1,10 +1,50 @@ import { AuthgearError, CancelError } from "@authgear/core"; -type _ErrorIdentificationFunction = (e: unknown) => boolean; +// iOS LocalAuthentication +const kLAErrorDomain = "com.apple.LocalAuthentication"; +// const kLAErrorAuthenticationFailed = -1; +const kLAErrorUserCancel = -2; +// const kLAErrorUserFallback = -3; +// const kLAErrorSystemCancel = -4; +const kLAErrorPasscodeNotSet = -5; +// const kLAErrorAppCancel = -9; +// const kLAErrorInvalidContext = -10; +// const kLAErrorWatchNotAvailable = -11; +// const kLAErrorNotInteractive = -1004; +const kLAErrorBiometryNotAvailable = -6; +const kLAErrorBiometryNotEnrolled = -7; +const kLAErrorBiometryLockout = -8; -const _errorMappings: [_ErrorIdentificationFunction, typeof AuthgearError][] = [ - [_isCancel, CancelError], -]; +// iOS Keychain +const NSOSStatusErrorDomain = "NSOSStatusErrorDomain"; +const errSecUserCanceled = -128; +//const errSecAuthFailed = -25293; +const errSecItemNotFound = -25300; + +// Android BiometricManager.canAuthenticate +const BIOMETRIC_ERROR_HW_UNAVAILABLE = "BIOMETRIC_ERROR_HW_UNAVAILABLE"; +const BIOMETRIC_ERROR_NONE_ENROLLED = "BIOMETRIC_ERROR_NONE_ENROLLED"; +const BIOMETRIC_ERROR_NO_HARDWARE = "BIOMETRIC_ERROR_NO_HARDWARE"; +const BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED = + "BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED"; +const BIOMETRIC_ERROR_UNSUPPORTED = "BIOMETRIC_ERROR_UNSUPPORTED"; +// const BIOMETRIC_STATUS_UNKNOWN = "BIOMETRIC_STATUS_UNKNOWN"; + +// Android BiometricPrompt +const ERROR_CANCELED = "ERROR_CANCELED"; +const ERROR_HW_NOT_PRESENT = "ERROR_HW_NOT_PRESENT"; +const ERROR_HW_UNAVAILABLE = "ERROR_HW_UNAVAILABLE"; +const ERROR_LOCKOUT = "ERROR_LOCKOUT"; +const ERROR_LOCKOUT_PERMANENT = "ERROR_LOCKOUT_PERMANENT"; +const ERROR_NEGATIVE_BUTTON = "ERROR_NEGATIVE_BUTTON"; +const ERROR_NO_BIOMETRICS = "ERROR_NO_BIOMETRICS"; +const ERROR_NO_DEVICE_CREDENTIAL = "ERROR_NO_DEVICE_CREDENTIAL"; +// const ERROR_NO_SPACE = "ERROR_NO_SPACE"; +const ERROR_SECURITY_UPDATE_REQUIRED = "ERROR_SECURITY_UPDATE_REQUIRED"; +// const ERROR_TIMEOUT = "ERROR_TIMEOUT"; +// const ERROR_UNABLE_TO_PROCESS = "ERROR_UNABLE_TO_PROCESS"; +const ERROR_USER_CANCELED = "ERROR_USER_CANCELED"; +// const ERROR_VENDOR = "ERROR_VENDOR"; // { // "errorMessage": "CANCEL", @@ -18,13 +58,22 @@ const _errorMappings: [_ErrorIdentificationFunction, typeof AuthgearError][] = [ // }, // "code": "CANCEL" // } + +/** + * @internal + */ export interface PlatformError { message: string; errorMessage: string; code: string; - data?: { - cause?: { - // iOS +} + +/** + * @internal + */ +export interface PlatformErrorIOSWithCause extends PlatformError { + data: { + cause: { domain: string; code: number; userInfo?: unknown; @@ -32,6 +81,9 @@ export interface PlatformError { }; } +/** + * @internal + */ export function isPlatformError(e: unknown): e is PlatformError { return ( typeof e === "object" && @@ -42,13 +94,76 @@ export function isPlatformError(e: unknown): e is PlatformError { ); } -export function _isCancel(e: unknown): boolean { - if (isPlatformError(e)) { - return e.code === "CANCEL"; - } - return false; +/** + * @internal + */ +export function isPlatformErrorIOS(e: unknown): e is PlatformErrorIOSWithCause { + return ( + isPlatformError(e) && + "data" in e && + typeof (e as any).data === "object" && + (e as any).data != null && + "cause" in (e as any).data + ); } +/** + * BiometricPrivateKeyNotFoundError means the biometric has changed so that + * the private key has been invalidated. + * + * @public + */ +export class BiometricPrivateKeyNotFoundError extends AuthgearError {} + +/** + * BiometricNotSupportedOrPermissionDeniedError means this device does not support biometric, + * or the user has denied the usage of biometric. + * + * @public + */ +export class BiometricNotSupportedOrPermissionDeniedError extends AuthgearError {} + +/** + * BiometricNoPasscodeError means the device does not have a passcode. + * You should prompt the user to setup a password for their device. + * + * @public + */ +export class BiometricNoPasscodeError extends AuthgearError {} + +/** + * BiometricNoEnrollmentError means the user has not setup biometric. + * You should prompt the user to do so. + * + * @public + */ +export class BiometricNoEnrollmentError extends AuthgearError {} + +/** + * BiometricLockoutError means the biometric is locked due to too many failed attempts. + * + * @public + */ +export class BiometricLockoutError extends AuthgearError {} + +type _ErrorIdentificationFunction = (e: unknown) => boolean; + +const _errorMappings: [_ErrorIdentificationFunction, typeof AuthgearError][] = [ + [_isBiometricPrivateKeyNotFoundError, BiometricPrivateKeyNotFoundError], + [_isBiometricCancel, CancelError], + [ + _isBiometricNotSupportedOrPermissionDeniedError, + BiometricNotSupportedOrPermissionDeniedError, + ], + [_isBiometricNoEnrollmentError, BiometricNoEnrollmentError], + [_isBiometricNoPasscodeError, BiometricNoPasscodeError], + [_isBiometricLockoutError, BiometricLockoutError], + [_isCancel, CancelError], +]; + +/** + * @internal + */ export function _wrapError(e: unknown): unknown { for (const [f, cls] of _errorMappings) { if (f(e)) { @@ -61,3 +176,126 @@ export function _wrapError(e: unknown): unknown { err.underlyingError = e; return err; } + +export function _isCancel(e: unknown): boolean { + if (isPlatformError(e)) { + return e.code === "CANCEL"; + } + return false; +} + +/** + * @internal + */ +export function _isBiometricPrivateKeyNotFoundError(e: unknown): boolean { + if (isPlatformErrorIOS(e)) { + return ( + e.data.cause.domain === NSOSStatusErrorDomain && + e.data.cause.code === errSecItemNotFound + ); + } + if (isPlatformError(e)) { + return ( + e.code === "android.security.keystore.KeyPermanentlyInvalidatedException" + ); + } + return false; +} + +/** + * @internal + */ +export function _isBiometricCancel(e: unknown): boolean { + if (isPlatformErrorIOS(e)) { + return ( + (e.data.cause.domain === kLAErrorDomain && + e.data.cause.code === kLAErrorUserCancel) || + (e.data.cause.domain === NSOSStatusErrorDomain && + e.data.cause.code === errSecUserCanceled) + ); + } + if (isPlatformError(e)) { + return ( + e.code === ERROR_CANCELED || + e.code === ERROR_NEGATIVE_BUTTON || + e.code === ERROR_USER_CANCELED + ); + } + return false; +} + +/** + * @internal + */ +export function _isBiometricNotSupportedOrPermissionDeniedError( + e: unknown +): boolean { + if (isPlatformErrorIOS(e)) { + return ( + e.data.cause.domain === kLAErrorDomain && + e.data.cause.code === kLAErrorBiometryNotAvailable + ); + } + if (isPlatformError(e)) { + return ( + e.code === BIOMETRIC_ERROR_HW_UNAVAILABLE || + e.code === BIOMETRIC_ERROR_NO_HARDWARE || + e.code === BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED || + e.code === BIOMETRIC_ERROR_UNSUPPORTED || + e.code === ERROR_HW_NOT_PRESENT || + e.code === ERROR_HW_UNAVAILABLE || + e.code === ERROR_SECURITY_UPDATE_REQUIRED + ); + } + return false; +} + +/** + * @internal + */ +export function _isBiometricNoEnrollmentError(e: unknown): boolean { + if (isPlatformErrorIOS(e)) { + return ( + e.data.cause.domain === kLAErrorDomain && + e.data.cause.code === kLAErrorBiometryNotEnrolled + ); + } + if (isPlatformError(e)) { + return ( + e.code === BIOMETRIC_ERROR_NONE_ENROLLED || e.code === ERROR_NO_BIOMETRICS + ); + } + return false; +} + +/** + * @internal + */ +export function _isBiometricNoPasscodeError(e: unknown): boolean { + if (isPlatformErrorIOS(e)) { + return ( + e.data.cause.domain === kLAErrorDomain && + e.data.cause.code === kLAErrorPasscodeNotSet + ); + } + if (isPlatformError(e)) { + return e.code === ERROR_NO_DEVICE_CREDENTIAL; + } + return false; +} + +/** + * @internal + */ +export function _isBiometricLockoutError(e: unknown): boolean { + if (isPlatformErrorIOS(e)) { + return ( + e.data.cause.domain === kLAErrorDomain && + e.data.cause.code === kLAErrorBiometryLockout + ); + } + if (isPlatformError(e)) { + return e.code === ERROR_LOCKOUT || e.code === ERROR_LOCKOUT_PERMANENT; + } + return false; +} diff --git a/packages/authgear-capacitor/src/index.ts b/packages/authgear-capacitor/src/index.ts index 99009988..a3dff663 100644 --- a/packages/authgear-capacitor/src/index.ts +++ b/packages/authgear-capacitor/src/index.ts @@ -4,6 +4,7 @@ import { type TokenStorage, type UserInfo, AuthgearError, + OAuthError, SessionState, SessionStateChangeReason, Page, @@ -16,7 +17,16 @@ import { } from "@authgear/core"; import { PersistentContainerStorage, PersistentTokenStorage } from "./storage"; import { generateCodeVerifier, computeCodeChallenge } from "./pkce"; -import { getDeviceInfo, openAuthorizeURL, openURL } from "./plugin"; +import { + generateUUID, + getDeviceInfo, + openAuthorizeURL, + openURL, + createBiometricPrivateKey, + checkBiometricSupported, + removeBiometricPrivateKey, + signWithBiometricPrivateKey, +} from "./plugin"; import { type CapacitorContainerDelegate, type AuthenticateOptions, @@ -24,12 +34,21 @@ import { type ReauthenticateOptions, type ReauthenticateResult, type SettingOptions, + type BiometricOptions, } from "./types"; +import { BiometricPrivateKeyNotFoundError } from "./error"; import { Capacitor } from "@capacitor/core"; export * from "@authgear/core"; export * from "./types"; export * from "./storage"; +export { + BiometricPrivateKeyNotFoundError, + BiometricNotSupportedOrPermissionDeniedError, + BiometricNoPasscodeError, + BiometricNoEnrollmentError, + BiometricLockoutError, +} from "./error"; function getPlatform(): string { const platform = Capacitor.getPlatform(); @@ -376,7 +395,7 @@ export class CapacitorContainer { x_device_info: xDeviceInfo, } ); - // await this.disableBiometric(); + await this.disableBiometric(); return result; } @@ -388,13 +407,14 @@ export class CapacitorContainer { * @public */ async reauthenticate( - options: ReauthenticateOptions + options: ReauthenticateOptions, + biometricOptions?: BiometricOptions ): Promise { // Use biometric to reauthenticate if the developer instructs us to do so. - // const biometricEnabled = await this.isBiometricEnabled(); - // if (biometricEnabled && biometricOptions != null) { - // return this.authenticateBiometric(biometricOptions); - // } + const biometricEnabled = await this.isBiometricEnabled(); + if (biometricEnabled && biometricOptions != null) { + return this.authenticateBiometric(biometricOptions); + } const platform = getPlatform(); @@ -548,6 +568,127 @@ export class CapacitorContainer { ], }); } + + /** + * @public + */ + // eslint-disable-next-line class-methods-use-this + async checkBiometricSupported(options: BiometricOptions): Promise { + await checkBiometricSupported(options); + } + + /** + * @public + */ + async isBiometricEnabled(): Promise { + const keyID = await this.storage.getBiometricKeyID(this.name); + return keyID != null; + } + + async disableBiometric(): Promise { + const keyID = await this.storage.getBiometricKeyID(this.name); + if (keyID != null) { + await removeBiometricPrivateKey(keyID); + await this.storage.delBiometricKeyID(this.name); + } + } + + async enableBiometric(options: BiometricOptions): Promise { + const clientID = this.clientID; + if (clientID == null) { + throw new AuthgearError("missing client ID"); + } + await this.refreshAccessTokenIfNeeded(); + const accessToken = this.accessToken; + if (accessToken == null) { + throw new AuthgearError("enableBiometric requires authenticated user"); + } + + const kid = await generateUUID(); + const deviceInfo = await getDeviceInfo(); + const { token } = await this.baseContainer.apiClient.oauthChallenge( + "biometric_request" + ); + const now = Math.floor(+new Date() / 1000); + const payload = { + iat: now, + exp: now + 300, + challenge: token, + action: "setup", + device_info: deviceInfo, + }; + const jwt = await createBiometricPrivateKey({ + ...options, + kid, + payload, + }); + await this.baseContainer.apiClient._setupBiometricRequest({ + access_token: accessToken, + client_id: clientID, + jwt, + }); + await this.storage.setBiometricKeyID(this.name, kid); + } + + async authenticateBiometric( + options: BiometricOptions + ): Promise { + const kid = await this.storage.getBiometricKeyID(this.name); + if (kid == null) { + throw new AuthgearError("biometric key ID not found"); + } + const clientID = this.clientID; + if (clientID == null) { + throw new AuthgearError("missing client ID"); + } + const deviceInfo = await getDeviceInfo(); + const { token } = await this.baseContainer.apiClient.oauthChallenge( + "biometric_request" + ); + const now = Math.floor(+new Date() / 1000); + const payload = { + iat: now, + exp: now + 300, + challenge: token, + action: "authenticate", + device_info: deviceInfo, + }; + + try { + const jwt = await signWithBiometricPrivateKey({ + ...options, + kid, + payload, + }); + const tokenResponse = + await this.baseContainer.apiClient._oidcTokenRequest({ + grant_type: "urn:authgear:params:oauth:grant-type:biometric-request", + client_id: clientID, + jwt, + }); + + const userInfo = await this.baseContainer.apiClient._oidcUserInfoRequest( + tokenResponse.access_token + ); + await this.baseContainer._persistTokenResponse( + tokenResponse, + SessionStateChangeReason.Authenticated + ); + return { userInfo }; + } catch (e: unknown) { + if (e instanceof BiometricPrivateKeyNotFoundError) { + await this.disableBiometric(); + } + if ( + e instanceof OAuthError && + e.error === "invalid_grant" && + e.error_description === "InvalidCredentials" + ) { + await this.disableBiometric(); + } + throw e; + } + } } /** diff --git a/packages/authgear-capacitor/src/plugin.ts b/packages/authgear-capacitor/src/plugin.ts index 48db76f0..4361973c 100644 --- a/packages/authgear-capacitor/src/plugin.ts +++ b/packages/authgear-capacitor/src/plugin.ts @@ -1,4 +1,5 @@ import { registerPlugin } from "@capacitor/core"; +import { BiometricPrivateKeyOptions, BiometricOptions } from "./types"; import { _wrapError } from "./error"; export interface AuthgearPlugin { @@ -8,12 +9,21 @@ export interface AuthgearPlugin { randomBytes(options: { length: number }): Promise<{ bytes: number[] }>; sha256String(options: { input: string }): Promise<{ bytes: number[] }>; getDeviceInfo(): Promise<{ deviceInfo: unknown }>; + generateUUID(): Promise<{ uuid: string }>; openAuthorizeURL(options: { url: string; callbackURL: string; prefersEphemeralWebBrowserSession: boolean; }): Promise<{ redirectURI: string }>; openURL(options: { url: string }): Promise; + checkBiometricSupported(options: BiometricOptions): Promise; + createBiometricPrivateKey( + options: BiometricPrivateKeyOptions + ): Promise<{ jwt: string }>; + signWithBiometricPrivateKey( + options: BiometricPrivateKeyOptions + ): Promise<{ jwt: string }>; + removeBiometricPrivateKey(options: { kid: string }): Promise; } const Authgear = registerPlugin("Authgear", {}); @@ -73,6 +83,15 @@ export async function getDeviceInfo(): Promise { } } +export async function generateUUID(): Promise { + try { + const { uuid } = await Authgear.generateUUID(); + return uuid; + } catch (e: unknown) { + throw _wrapError(e); + } +} + export async function openAuthorizeURL(options: { url: string; callbackURL: string; @@ -93,3 +112,43 @@ export async function openURL(options: { url: string }): Promise { throw _wrapError(e); } } + +export async function checkBiometricSupported( + options: BiometricOptions +): Promise { + try { + await Authgear.checkBiometricSupported(options); + } catch (e: unknown) { + throw _wrapError(e); + } +} + +export async function createBiometricPrivateKey( + options: BiometricPrivateKeyOptions +): Promise { + try { + const { jwt } = await Authgear.createBiometricPrivateKey(options); + return jwt; + } catch (e: unknown) { + throw _wrapError(e); + } +} + +export async function signWithBiometricPrivateKey( + options: BiometricPrivateKeyOptions +): Promise { + try { + const { jwt } = await Authgear.signWithBiometricPrivateKey(options); + return jwt; + } catch (e: unknown) { + throw _wrapError(e); + } +} + +export async function removeBiometricPrivateKey(kid: string): Promise { + try { + await Authgear.removeBiometricPrivateKey({ kid }); + } catch (e: unknown) { + throw _wrapError(e); + } +} diff --git a/packages/authgear-capacitor/src/types.ts b/packages/authgear-capacitor/src/types.ts index 10cf4a4e..2fa741c6 100644 --- a/packages/authgear-capacitor/src/types.ts +++ b/packages/authgear-capacitor/src/types.ts @@ -151,3 +151,71 @@ export interface SettingOptions { */ colorScheme?: ColorScheme; } + +/** + * @public + */ +export enum BiometricLAPolicy { + deviceOwnerAuthenticationWithBiometrics = "deviceOwnerAuthenticationWithBiometrics", + deviceOwnerAuthentication = "deviceOwnerAuthentication", +} + +/** + * @public + */ +export enum BiometricAccessConstraintIOS { + BiometricAny = "biometryAny", + BiometryCurrentSet = "biometryCurrentSet", + UserPresence = "userPresence", +} + +/** + * @public + */ +export interface BiometricOptionsIOS { + localizedReason: string; + constraint: BiometricAccessConstraintIOS; + policy: BiometricLAPolicy; +} + +/** + * @public + */ +export enum BiometricAccessConstraintAndroid { + BiometricStrong = "BIOMETRIC_STRONG", + DeviceCredential = "DEVICE_CREDENTIAL", +} + +/** + * @public + */ +export interface BiometricOptionsAndroid { + title: string; + subtitle: string; + description: string; + negativeButtonText: string; + constraint: BiometricAccessConstraintAndroid[]; + invalidatedByBiometricEnrollment: boolean; +} + +/** + * @public + */ +export interface BiometricOptions { + ios: BiometricOptionsIOS; + android: BiometricOptionsAndroid; +} + +/** + * @internal + */ +export interface BiometricPrivateKeyOptions extends BiometricOptions { + kid: string; + payload: { + iat: number; + exp: number; + challenge: string; + action: string; + device_info: unknown; + }; +} From 946fa616c4901156b978c11cca483ce74dbb8a98 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 12 Jan 2024 14:36:36 +0800 Subject: [PATCH 04/15] Port iOS code of biometric --- .../ios/Plugin.xcodeproj/project.pbxproj | 8 + .../ios/Plugin/ASN1DERParsing.swift | 230 +++++++++++++ .../ios/Plugin/Asn1IntegerConversion.swift | 40 +++ .../ios/Plugin/AuthgearPlugin.m | 5 + .../ios/Plugin/AuthgearPlugin.swift | 93 ++++- .../ios/Plugin/AuthgearPluginImpl.swift | 325 ++++++++++++++++++ 6 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 packages/authgear-capacitor/ios/Plugin/ASN1DERParsing.swift create mode 100644 packages/authgear-capacitor/ios/Plugin/Asn1IntegerConversion.swift diff --git a/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj b/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj index fcad5d86..b38f2bce 100644 --- a/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj +++ b/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 50ADFFA82020EE4F00D50D53 /* AuthgearPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = 50ADFFA72020EE4F00D50D53 /* AuthgearPlugin.m */; }; 50E1A94820377CB70090CE1A /* AuthgearPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E1A94720377CB70090CE1A /* AuthgearPlugin.swift */; }; 7774CCB12B302A7F007420F2 /* AuthgearPluginImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */; }; + 77F8B4232B4FDFB700A6F088 /* Asn1IntegerConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */; }; + 77F8B4252B4FE08700A6F088 /* ASN1DERParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -39,6 +41,8 @@ 50E1A94720377CB70090CE1A /* AuthgearPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthgearPlugin.swift; sourceTree = ""; }; 5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.debug.xcconfig"; sourceTree = ""; }; 7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthgearPluginImpl.swift; sourceTree = ""; }; + 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asn1IntegerConversion.swift; sourceTree = ""; }; + 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1DERParsing.swift; sourceTree = ""; }; 91781294A431A2A7CC6EB714 /* Pods-Plugin.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.release.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.release.xcconfig"; sourceTree = ""; }; 96ED1B6440D6672E406C8D19 /* Pods-PluginTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PluginTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-PluginTests/Pods-PluginTests.debug.xcconfig"; sourceTree = ""; }; F65BB2953ECE002E1EF3E424 /* Pods-PluginTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PluginTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-PluginTests/Pods-PluginTests.release.xcconfig"; sourceTree = ""; }; @@ -95,6 +99,8 @@ 50ADFFA72020EE4F00D50D53 /* AuthgearPlugin.m */, 7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */, 50ADFF8C201F53D600D50D53 /* Info.plist */, + 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */, + 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */, ); path = Plugin; sourceTree = ""; @@ -303,7 +309,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 77F8B4252B4FE08700A6F088 /* ASN1DERParsing.swift in Sources */, 50E1A94820377CB70090CE1A /* AuthgearPlugin.swift in Sources */, + 77F8B4232B4FDFB700A6F088 /* Asn1IntegerConversion.swift in Sources */, 50ADFFA82020EE4F00D50D53 /* AuthgearPlugin.m in Sources */, 7774CCB12B302A7F007420F2 /* AuthgearPluginImpl.swift in Sources */, ); diff --git a/packages/authgear-capacitor/ios/Plugin/ASN1DERParsing.swift b/packages/authgear-capacitor/ios/Plugin/ASN1DERParsing.swift new file mode 100644 index 00000000..ca6c187c --- /dev/null +++ b/packages/authgear-capacitor/ios/Plugin/ASN1DERParsing.swift @@ -0,0 +1,230 @@ +// swiftformat:disable all + +// Copied from https://github.com/airsidemobile/JOSESwift/blob/2.4.0/JOSESwift/Sources/ASN1DERParsing.swift + +// +// ASN1DERParsing.swift +// JOSESwift +// +// Created by Daniel Egger on 06.02.18. +// +// --------------------------------------------------------------------------- +// Copyright 2019 Airside Mobile Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// --------------------------------------------------------------------------- +// + +import Foundation + +internal enum ASN1DERParsingError: Error { + case incorrectTypeTag(actualTag: UInt8, expectedTag: UInt8) + case incorrectLengthFieldLength + case incorrectValueLength + case incorrectTLVLength +} + +/// Possible ASN.1 types. +/// See [here](https://msdn.microsoft.com/en-us/library/windows/desktop/bb648640(v=vs.85).aspx) +/// for more information. +internal enum ASN1Type { + case sequence + case integer + + var tag: UInt8 { + switch self { + case .sequence: + return 0x30 + case .integer: + return 0x02 + } + } +} + +internal struct TLVTriplet { + let tag: UInt8 + let length: [UInt8] + let value: [UInt8] +} + +// MARK: Array Extension for Parsing +// Inspired by: https://github.com/henrinormak/Heimdall/blob/master/Heimdall/Heimdall.swift + +internal extension Array where Element == UInt8 { + + /// Reads the value of the specified ASN.1 type from the front of the bytes array. + /// The bytes array is expected to be a DER encoding of an ASN.1 type. + /// The specified type's TLV triplet is expected to be at the front of the bytes array. + /// The bytes array may contain trailing bytes after the TLV triplet that are ignored during parsing. + /// + /// - Parameter type: The ASN.1 type to read. + /// More information about the expected DER encoding of the specified ASN.1 type can be found + /// [here](https://msdn.microsoft.com/en-us/library/windows/desktop/bb648640(v=vs.85).aspx). + /// - Returns: The value of the specified ASN.1 type. More formally, the value field of the type's TLV triplet. + /// - Throws: An `ASN1DERParsingError` indicating any parsing errors. + func read(_ type: ASN1Type) throws -> [UInt8] { + let triplet = try self.nextTLVTriplet() + + guard triplet.tag == type.tag else { + throw ASN1DERParsingError.incorrectTypeTag(actualTag: triplet.tag, expectedTag: type.tag) + } + + return triplet.value + } + + /// Removes the specified ASN.1 type from the bytes array. + /// The bytes array is expected to be a DER encoding of a ASN.1 type. + /// The specified type's TLV triplet is expected to be at the front of the bytes array. + /// + /// - Parameter type: The ASN.1 type to be removed from the bytes array. + /// More information about the expected DER encoding of the specified ASN.1 type can be found + /// [here](https://msdn.microsoft.com/en-us/library/windows/desktop/bb648640(v=vs.85).aspx). + /// - Returns: The remaining bytes of the bytes array that may contain further ASN.1 types. + /// - Throws: An `ASN1DERParsingError` indicating any parsing errors. + func skip(_ type: ASN1Type) throws -> [UInt8] { + let triplet = try self.nextTLVTriplet() + + guard triplet.tag == type.tag else { + throw ASN1DERParsingError.incorrectTypeTag(actualTag: triplet.tag, expectedTag: type.tag) + } + + // TLV triplet = 1 tag byte + some length bytes + some value bytes + let skippedTripletLength = (1 + triplet.length.count + triplet.value.count) + + return Array(self.dropFirst(skippedTripletLength)) + } + + /// Reads a TLV (tag, length, value) triplet of a DER encoded ASN.1 type from the bytes array. + /// More information on the DER Transfer Syntax encoding ASN.1 types can be found + /// [here](https://msdn.microsoft.com/en-us/library/windows/desktop/bb540801(v=vs.85).aspx). + /// + /// - Returns: A triplet containing the ASN.1 type's tag, length, and value field. + func nextTLVTriplet() throws -> TLVTriplet { + var pointer = 0 + + // DER encoding of an ASN.1 type: [ TAG | LENGTH | VALUE ]. + + // At least the tag and one length byte must be present. + guard self.count >= 2 else { + throw ASN1DERParsingError.incorrectTLVLength + } + + let tag = readTag(from: self, pointer: &pointer) + + let lengthField = try readLengthField(from: self, pointer: &pointer) + + let valueFieldLength = try length(encodedBy: lengthField) + + let valueField = try readValueField(ofLength: valueFieldLength, from: self, pointer: &pointer) + + return TLVTriplet(tag: tag, length: lengthField, value: valueField) + } + +} + +// MARK: Freestanding Helper Functions + +private func readTag(from encodedTriplet: [UInt8], pointer: inout Int) -> UInt8 { + let tag = encodedTriplet[pointer] + + // ---------------------------------------- // + // tag length field value field // + // [ 0xN | ............ | ........... ] // + // ^ // + // | // + // pointer // + // ---------------------------------------- // + + pointer.advance() + + return tag +} + +private func readLengthField(from encodedTriplet: [UInt8], pointer: inout Int) throws -> [UInt8] { + if encodedTriplet[pointer] < 128 { + let lengthField = [ encodedTriplet[pointer] ] + pointer.advance() + + return lengthField + } + + // -------------------------------------------------- // + // tag length field value field // + // [ ... | 0x8N 0x00 0x01 ... 0xN | ........... ] // + // ^ | | // + // | -------v------- // + // | lengthFieldCount // + // | // + // pointer // + // -------------------------------------------------- // + + let lengthFieldCount = Int(encodedTriplet[pointer] - 128) + + // Ensure we have enough bytes left. + guard (pointer + lengthFieldCount) < encodedTriplet.count else { + throw ASN1DERParsingError.incorrectLengthFieldLength + } + + let lengthField = Array(encodedTriplet[pointer...(pointer + lengthFieldCount)]) + + pointer.advance() + pointer.advance(by: lengthFieldCount) + + return lengthField +} + +private func readValueField(ofLength length: Int, from encodedTriplet: [UInt8], pointer: inout Int) throws -> [UInt8] { + let endPointer = (pointer + length) + + // --------------------------------------------------------------- // + // tag length field value field // + // [ ... | ............ | 0x01 0x02 0x03 0x04 ... 0xN ] // + // ^ ^ // + // | | // + // pointer endPointer // + // --------------------------------------------------------------- // + + // Ensure we have enough bytes left. + guard endPointer <= encodedTriplet.count else { + throw ASN1DERParsingError.incorrectValueLength + } + + return Array(encodedTriplet[pointer.. Int { + // If the value field contains < 128 bytes, the length field requires only one byte (00000010 = length two). + // If the value field contains >= 128 bytes, the highest bit of the length field is 1 and the remaining bits + // identify the number of bytes needed to encode the length (10000010 - 10000000 = 10 = two length bytes follow). + + // Length is directly encoded by the only byte in the length field. + if lengthField.count == 1 { + return Int(lengthField[0]) + } + + // Length is encoded by all but the first byte in the length field. + // The first byte in the length field encodes the number of remaining bytes used to encode the length. + var length: UInt64 = 0 + for byte in lengthField.dropFirst() { + length = (length << 8) + length += UInt64(byte) + } + + return Int(length) +} + +private extension Int { + mutating func advance(by n: Int = 1) { + self = self.advanced(by: n) + } +} diff --git a/packages/authgear-capacitor/ios/Plugin/Asn1IntegerConversion.swift b/packages/authgear-capacitor/ios/Plugin/Asn1IntegerConversion.swift new file mode 100644 index 00000000..951b66ba --- /dev/null +++ b/packages/authgear-capacitor/ios/Plugin/Asn1IntegerConversion.swift @@ -0,0 +1,40 @@ +// swiftforamt:disable all + +import Foundation + +// Copied from https://github.com/airsidemobile/JOSESwift/blob/2.4.0/JOSESwift/Sources/CryptoImplementation/EC.swift#L229 +enum Asn1IntegerConversion { + static func toRaw(_ data: Data, of fixedLength: Int) -> Data { + let varLength = data.count + if varLength > fixedLength + 1 { + fatalError("ASN.1 integer is \(varLength) bytes long when it should be < \(fixedLength + 1).") + } + if varLength == fixedLength + 1 { + assert(data.first == 0) + return data.dropFirst() + } + if varLength == fixedLength { + return data + } + if varLength < fixedLength { + // pad to fixed length using 0x00 bytes + return Data(count: fixedLength - varLength) + data + } + fatalError("Unable to parse ASN.1 integer. This should be unreachable.") + } + + static func fromRaw(_ data: Data) -> Data { + assert(!data.isEmpty) + let msb: UInt8 = 0b1000_0000 + // drop all leading zero bytes + let varlen = data.drop { $0 == 0 } + guard let firstNonZero = varlen.first else { + // all bytes were zero so the encoded value is zero + return Data(count: 1) + } + if (firstNonZero & msb) == msb { + return Data(count: 1) + varlen + } + return varlen + } +} diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m index c20d4d5e..d46e4f63 100644 --- a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m +++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m @@ -10,6 +10,11 @@ CAP_PLUGIN_METHOD(randomBytes, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(sha256String, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getDeviceInfo, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(generateUUID, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(openAuthorizeURL, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(openURL, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(checkBiometricSupported, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(createBiometricPrivateKey, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(signWithBiometricPrivateKey, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(removeBiometricPrivateKey, CAPPluginReturnPromise); ) diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift index be1bd4f8..a70aab10 100644 --- a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift +++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift @@ -1,4 +1,5 @@ import Foundation +import LocalAuthentication import Capacitor @objc(AuthgearPlugin) @@ -70,12 +71,19 @@ public class AuthgearPlugin: CAPPlugin { } @objc func getDeviceInfo(_ call: CAPPluginCall) { - let deviceInfo = self.impl.getDeviceInfo(); + let deviceInfo = self.impl.getDeviceInfo() call.resolve([ "deviceInfo": deviceInfo ]) } + @objc func generateUUID(_ call: CAPPluginCall) { + let uuid = self.impl.generateUUID() + call.resolve([ + "uuid": uuid + ]) + } + @objc func openAuthorizeURL(_ call: CAPPluginCall) { let url = URL(string: call.getString("url")!)! let callbackURL = URL(string: call.getString("callbackURL")!)! @@ -108,4 +116,87 @@ public class AuthgearPlugin: CAPPlugin { } } } + + @objc func checkBiometricSupported(_ call: CAPPluginCall) { + DispatchQueue.main.async { + do { + try self.impl.checkBiometricSupported() + call.resolve() + } catch { + error.reject(call) + } + } + } + + @objc func createBiometricPrivateKey(_ call: CAPPluginCall) { + let kid = call.getString("kid")! + let payload = call.getObject("payload")! + let ios = call.getObject("ios")! + let constraint = ios["constraint"] as! String + let localizedReason = ios["localizedReason"] as! String + let tag = "com.authgear.keys.biometric.\(kid)" + let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics + + DispatchQueue.main.async { + self.impl.createBiometricPrivateKey( + policy: policy, + localizedReason: localizedReason, + constraint: constraint, + kid: kid, + tag: tag, + payload: payload + ) { (jwt, error) in + if let error = error { + error.reject(call) + } + if let jwt = jwt { + call.resolve([ + "jwt": jwt + ]) + } + } + } + } + + @objc func signWithBiometricPrivateKey(_ call: CAPPluginCall) { + let kid = call.getString("kid")! + let payload = call.getObject("payload")! + let ios = call.getObject("ios")! + let policyString = ios["policy"] as! String + let localizedReason = ios["localizedReason"] as! String + let tag = "com.authgear.keys.biometric.\(kid)" + + DispatchQueue.main.async { + self.impl.signWithBiometricPrivateKey( + policyString: policyString, + localizedReason: localizedReason, + kid: kid, + tag: tag, + payload: payload + ) { (jwt, error) in + if let error = error { + error.reject(call) + } + if let jwt = jwt { + call.resolve([ + "jwt": jwt + ]) + } + } + } + } + + @objc func removeBiometricPrivateKey(_ call: CAPPluginCall) { + let kid = call.getString("kid")! + let tag = "com.authgear.keys.biometric.\(kid)" + + DispatchQueue.main.async { + do { + try self.impl.removeBiometricPrivateKey(tag: tag) + call.resolve() + } catch { + error.reject(call) + } + } + } } diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift index 169952a3..45828bcb 100644 --- a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift +++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift @@ -2,6 +2,7 @@ import Foundation import CommonCrypto import UIKit import AuthenticationServices +import LocalAuthentication import Capacitor @objc class AuthgearPluginImpl: NSObject, ASWebAuthenticationPresentationContextProviding { @@ -177,6 +178,11 @@ import Capacitor ] } + @objc func generateUUID() -> String { + let uuid = NSUUID() + return uuid.uuidString + } + @objc func openAuthorizeURL(window: UIWindow, url: URL, callbackURL: URL, prefersEphemeralWebBrowserSession: Bool, completion: @escaping (String?, Error?) -> Void) { if #available(iOS 12.0, *) { let scheme = callbackURL.scheme @@ -242,10 +248,285 @@ import Capacitor } } + @objc func checkBiometricSupported() throws { + if #available(iOS 11.3, *) { + let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics + let laContext = self.makeLAContext(policy: policy) + var error: NSError? + laContext.canEvaluatePolicy(policy, error: &error) + if let error = error { + throw error + } + } else { + throw NSError.makeError(message: "Biometric authentication requires at least iOS 11.3", code: nil, error: nil) + } + } + + @objc func createBiometricPrivateKey( + policy: LAPolicy, + localizedReason: String, + constraint: String, + kid: String, + tag: String, + payload: [String: Any], + completion: @escaping (String?, Error?) -> Void + ) { + let ctx = makeLAContext(policy: policy) + ctx.evaluatePolicy(policy, localizedReason: localizedReason) { ok, error in + if let error = error { + completion(nil, error) + return + } + + do { + let privateKey = try self.generateAndAddBiometricPrivateKey(constraint: constraint, tag: tag, laContext: ctx) + let jwt = try self.signBiometricJWT(privateKey: privateKey, kid: kid, payload: payload) + completion(jwt, nil) + return + } catch { + completion(nil, error) + return + } + } + } + + @objc func signWithBiometricPrivateKey( + policyString: String, + localizedReason: String, + kid: String, + tag: String, + payload: [String: Any], + completion: @escaping (String?, Error?) -> Void + ) { + let policy = LAPolicy.from(string: policyString)! + let ctx = makeLAContext(policy: policy) + ctx.evaluatePolicy(policy, localizedReason: localizedReason) { ok, error in + if let error = error { + completion(nil, error) + return + } + + do { + let privateKey = try self.getBiometricPrivateKey(tag: tag, laContext: ctx) + let jwt = try self.signBiometricJWT(privateKey: privateKey, kid: kid, payload: payload) + completion(jwt, nil) + return + } catch { + completion(nil, error) + return + } + } + } + + @objc func removeBiometricPrivateKey(tag: String) throws { + let query: NSDictionary = [ + kSecClass: kSecClassKey, + // Do not specify the key type because it can be either RSA or EC. + kSecAttrApplicationTag: tag + ] + let status = SecItemDelete(query) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { let window = self.asWebAuthenticationSessionHandles[session]! return window } + + private func makeLAContext(policy: LAPolicy) -> LAContext { + let ctx = LAContext() + if policy == LAPolicy.deviceOwnerAuthenticationWithBiometrics { + ctx.localizedFallbackTitle = ""; + } + return ctx + } + + private func generateAndAddBiometricPrivateKey(constraint: String, tag: String, laContext: LAContext) throws -> SecKey { + var cfError: Unmanaged? + + var flags: SecAccessControlCreateFlags = [] + // https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/protecting_keys_with_the_secure_enclave + flags.insert(.privateKeyUsage) + + switch constraint { + case "biometryAny": + flags.insert(.biometryAny) + case "biometryCurrentSet": + flags.insert(.biometryCurrentSet) + case "userPresence": + flags.insert(.userPresence) + default: + break + } + + guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + flags, + &cfError + ) else { + throw cfError!.takeRetainedValue() as Error + } + + let attributes: NSDictionary = [ + kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits: 256, + kSecAttrTokenID: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs: [ + kSecClass: kSecClassKey, + kSecAttrIsPermanent: true, + kSecAttrApplicationTag: tag, + kSecAttrAccessControl: accessControl, + kSecUseAuthenticationContext: laContext + ] + ] + + guard let privateKey = SecKeyCreateRandomKey(attributes, &cfError) else { + throw cfError!.takeRetainedValue() as Error + } + + return privateKey + } + + private func getBiometricPrivateKey(tag: String, laContext: LAContext) throws -> SecKey { + let query: NSDictionary = [ + kSecClass: kSecClassKey, + kSecMatchLimit: kSecMatchLimitOne, + // Do not specify the key type because it can be either RSA or EC. + kSecAttrApplicationTag: tag, + kSecUseAuthenticationContext: laContext, + kSecReturnRef: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query, &item) + + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + + let privateKey = item as! SecKey + return privateKey + } + + private func signBiometricJWT(privateKey: SecKey, kid: String, payload: [String: Any]) throws -> String { + let jwk = try self.getJWKFromPrivateKey(privateKey: privateKey, kid: kid) + let header = [ + "typ": "vnd.authgear.biometric-request", + "kid": jwk["kid"], + "alg": jwk["alg"], + "jwk": jwk + ] + let jwt = try self.signJWT(privateKey: privateKey, header: header as [String: Any], payload: payload) + return jwt + } + + private func getJWKFromPrivateKey(privateKey: SecKey, kid: String) throws -> [String: Any] { + var cfError: Unmanaged? + + let publicKey = SecKeyCopyPublicKey(privateKey)! + guard let cfData = SecKeyCopyExternalRepresentation(publicKey, &cfError) else { + throw cfError!.takeRetainedValue() as Error + } + + let data = cfData as Data + + switch KeyType.from(privateKey)! { + case .rsa: + return getJWKFromRSA(kid: kid, data: data) + case .ec: + return try getJWKFromEC(kid: kid, data: data) + } + } + + private func getJWKFromRSA(kid: String, data: Data) -> [String: Any] { + let n = data.subdata(in: Range(NSRange(location: data.count > 269 ? 9 : 8, length: 256))!) + let e = data.subdata(in: Range(NSRange(location: data.count - 3, length: 3))!) + + return [ + "kid": kid, + "kty": "RSA", + "alg": "RS256", + "n": n.base64urlEncodedString(), + "e": e.base64urlEncodedString(), + ] + } + + private func getJWKFromEC(kid: String, data: Data) throws -> [String: Any] { + var publicKeyBytes = [UInt8](data) + + guard publicKeyBytes.removeFirst() == 0x04 else { + throw NSError.makeError(message: "unexpected ec public key format", code: nil, error: nil) + } + + let coordinateOctetLength = 32 + + let xBytes = publicKeyBytes[0.. String { + let headerJSON = try JSONSerialization.data(withJSONObject: header) + let payloadJSON = try JSONSerialization.data(withJSONObject: payload) + let headerString = headerJSON.base64urlEncodedString() + let payloadString = payloadJSON.base64urlEncodedString() + let stringToSign = "\(headerString).\(payloadString)" + let dataToSign = stringToSign.data(using: .utf8)! + let signature = try self.signData(privateKey: privateKey, data: dataToSign) + let signatureString = signature.base64urlEncodedString() + return "\(stringToSign).\(signatureString)" + } + + private func signData(privateKey: SecKey, data: Data) throws -> Data { + switch KeyType.from(privateKey)! { + case .rsa: + return try signRSA(privateKey: privateKey, data: data) + case .ec: + return try signEC(privateKey: privateKey, data: data) + } + } + + private func signRSA(privateKey: SecKey, data: Data) throws -> Data { + var cfError: Unmanaged? + guard let signature = SecKeyCreateSignature(privateKey, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &cfError) else { + throw cfError!.takeRetainedValue() as Error + } + return signature as Data + } + + private func signEC(privateKey: SecKey, data: Data) throws -> Data { + var cfError: Unmanaged? + guard let signature = SecKeyCreateSignature(privateKey, .ecdsaSignatureMessageX962SHA256, data as CFData, &cfError) else { + throw cfError!.takeRetainedValue() as Error + } + + // Convert the signature to correct format + // See https://github.com/airsidemobile/JOSESwift/blob/2.4.0/JOSESwift/Sources/CryptoImplementation/EC.swift#L208 + let coordinateOctetLength = 32 + + let ecSignatureTLV = [UInt8](signature as Data) + let ecSignature = try ecSignatureTLV.read(.sequence) + let varlenR = try Data(ecSignature.read(.integer)) + let varlenS = try Data(ecSignature.skip(.integer).read(.integer)) + let fixlenR = Asn1IntegerConversion.toRaw(varlenR, of: coordinateOctetLength) + let fixlenS = Asn1IntegerConversion.toRaw(varlenS, of: coordinateOctetLength) + + let fixedSignature = (fixlenR + fixlenS) + return fixedSignature + } } private extension UIUserInterfaceIdiom { @@ -338,3 +619,47 @@ extension Error { call.reject(message, code, underlyingError, data) } } + +private enum KeyType { + case rsa + case ec + + static func from(_ privateKey: SecKey) -> KeyType? { + guard let attributes = SecKeyCopyAttributes(privateKey) as? [CFString: Any], + let keyType = attributes[kSecAttrKeyType] as? String else { + return nil + } + + if (keyType == (kSecAttrKeyTypeECSECPrimeRandom as String)) { + return .ec + } + + if (keyType == (kSecAttrKeyTypeRSA as String)) { + return .rsa + } + + return nil + } +} + +private extension Data { + func base64urlEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +private extension LAPolicy { + static func from(string: String) -> LAPolicy? { + switch string { + case "deviceOwnerAuthenticationWithBiometrics": + return .deviceOwnerAuthenticationWithBiometrics + case "deviceOwnerAuthentication": + return .deviceOwnerAuthentication + default: + return nil + } + } +} From f5ef062ec810477f327e711716058a1db4a4ba67 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 12 Jan 2024 15:00:20 +0800 Subject: [PATCH 05/15] Fix plain NSError is not passed to JS --- .../ios/Plugin/AuthgearPluginImpl.swift | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift index 45828bcb..957f87e2 100644 --- a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift +++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift @@ -198,7 +198,7 @@ import Capacitor if isCancel { completion(nil, NSError.makeCancel(error: error)) } else { - completion(nil, NSError.makeError(message: "openAuthorizeURL failed", code: nil, error: error)) + completion(nil, NSError.makeUnrecoverableAuthgearError(message: "openAuthorizeURL failed", error: error)) } } if let redirectURI = redirectURI { @@ -212,7 +212,7 @@ import Capacitor } asWebSession!.start() } else { - completion(nil, NSError.makeError(message: "SDK supports only iOS 12.0 or newer", code: nil, error: nil)) + completion(nil, NSError.makeUnrecoverableAuthgearError(message: "SDK supports only iOS 12.0 or newer", error: nil)) } } @@ -231,7 +231,7 @@ import Capacitor if isCancel { completion(nil) } else { - completion(NSError.makeError(message: "openURL failed", code: nil, error: error)) + completion(NSError.makeUnrecoverableAuthgearError(message: "openURL failed", error: error)) } } else { completion(nil) @@ -244,7 +244,7 @@ import Capacitor } asWebSession!.start() } else { - completion(NSError.makeError(message: "SDK supports only iOS 12.0 or newer", code: nil, error: nil)) + completion(NSError.makeUnrecoverableAuthgearError(message: "SDK supports only iOS 12.0 or newer", error: nil)) } } @@ -258,7 +258,7 @@ import Capacitor throw error } } else { - throw NSError.makeError(message: "Biometric authentication requires at least iOS 11.3", code: nil, error: nil) + throw NSError.makeUnrecoverableAuthgearError(message: "Biometric authentication requires at least iOS 11.3", error: nil) } } @@ -458,7 +458,7 @@ import Capacitor var publicKeyBytes = [UInt8](data) guard publicKeyBytes.removeFirst() == 0x04 else { - throw NSError.makeError(message: "unexpected ec public key format", code: nil, error: nil) + throw NSError.makeUnrecoverableAuthgearError(message: "unexpected ec public key format", error: nil) } let coordinateOctetLength = 32 @@ -553,13 +553,10 @@ private extension UIUserInterfaceIdiom { extension NSError { static let AuthgearDomain = "Authgear" - static func makeError(message: String, code: String?, error: Error?) -> NSError { + static func makeUnrecoverableAuthgearError(message: String, error: Error?) -> NSError { var userInfo: [String: Any] = [ NSLocalizedDescriptionKey: message ] - if let code = code { - userInfo["code"] = code - } if let error = error { userInfo[NSUnderlyingErrorKey] = error } @@ -567,31 +564,37 @@ extension NSError { } static func makeCancel(error: Error?) -> NSError { - return makeError(message: "CANCEL", code: "CANCEL", error: error) + var userInfo: [String: Any] = [ + NSLocalizedDescriptionKey: "CANCEL" + ] + userInfo["code"] = "CANCEL" + if let error = error { + userInfo[NSUnderlyingErrorKey] = error + } + return NSError(domain: AuthgearDomain, code: 0, userInfo: userInfo) } - var capacitorCode: String? { + var capacitorMessage: String { get { - return self.userInfo["code"] as? String + return self.localizedDescription } } - var capacitorUnderlyingError: Error? { + var capacitorCode: String? { get { - return self.userInfo[NSUnderlyingErrorKey] as? Error + return self.userInfo["code"] as? String } } - var capacitorMessage: String { + var capacitorUnderlyingError: Error? { get { - return self.localizedDescription + return self.userInfo[NSUnderlyingErrorKey] as? Error } } var capacitorData: [String: Any]? { get { - let underlying = self.capacitorUnderlyingError - if let underlying = underlying { + if let underlying = self.capacitorUnderlyingError { let nsError = underlying as NSError let domain = nsError.domain let code = nsError.code @@ -603,8 +606,19 @@ extension NSError { "userInfo": userInfo ] ] + } else { + let nsError = self + let domain = nsError.domain + let code = nsError.code + let userInfo = nsError.userInfo + return [ + "cause": [ + "domain": domain, + "code": code, + "userInfo": userInfo + ] + ] } - return nil } } } From fedf57a5fc7494fa627bc2af5e8d7548a2b5af4c Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 12 Jan 2024 15:05:00 +0800 Subject: [PATCH 06/15] Demo biometric --- example/capacitor/ios/App/App/Info.plist | 2 + example/capacitor/src/pages/Home.tsx | 193 ++++++++++++++++++++++- 2 files changed, 191 insertions(+), 4 deletions(-) diff --git a/example/capacitor/ios/App/App/Info.plist b/example/capacitor/ios/App/App/Info.plist index 6a6ff2eb..60e98093 100644 --- a/example/capacitor/ios/App/App/Info.plist +++ b/example/capacitor/ios/App/App/Info.plist @@ -47,5 +47,7 @@ ITSAppUsesNonExemptEncryption + NSFaceIDUsageDescription + Use Face ID to authenticate diff --git a/example/capacitor/src/pages/Home.tsx b/example/capacitor/src/pages/Home.tsx index 9a42be93..a507694e 100644 --- a/example/capacitor/src/pages/Home.tsx +++ b/example/capacitor/src/pages/Home.tsx @@ -41,6 +41,10 @@ import authgearCapacitor, { CancelError as CapacitorCancelError, ColorScheme, Page as CapacitorPage, + BiometricOptions, + BiometricAccessConstraintIOS, + BiometricLAPolicy, + BiometricAccessConstraintAndroid, } from "@authgear/capacitor"; import { readClientID, @@ -59,6 +63,22 @@ const REDIRECT_URI_WEB_AUTHENTICATE = "http://localhost:8100/oauth-redirect"; const REDIRECT_URI_WEB_REAUTH = "http://localhost:8100/reauth-redirect"; const REDIRECT_URI_CAPACITOR = "com.authgear.exampleapp.capacitor://host/path"; +const biometricOptions: BiometricOptions = { + ios: { + localizedReason: "Use biometric to authenticate", + constraint: BiometricAccessConstraintIOS.BiometryCurrentSet, + policy: BiometricLAPolicy.deviceOwnerAuthenticationWithBiometrics, + }, + android: { + title: "Biometric Authentication", + subtitle: "Biometric authentication", + description: "Use biometric to authenticate", + negativeButtonText: "Cancel", + constraint: [BiometricAccessConstraintAndroid.BiometricStrong], + invalidatedByBiometricEnrollment: true, + }, +}; + function isPlatformWeb(): boolean { return Capacitor.getPlatform() === "web"; } @@ -83,6 +103,7 @@ function AuthgearDemo() { const [isSSOEnabled, setIsSSOEnabled] = useState(() => { return readIsSSOEnabled(); }); + const [biometricEnabled, setBiometricEnabled] = useState(false); const [sessionState, setSessionState] = useState(() => { if (isPlatformWeb()) { @@ -105,6 +126,20 @@ function AuthgearDemo() { return d; }, [setSessionState]); + const updateBiometricState = useCallback(async () => { + if (isPlatformWeb()) { + return; + } + + try { + await authgearCapacitor.checkBiometricSupported(biometricOptions); + const enabled = await authgearCapacitor.isBiometricEnabled(); + setBiometricEnabled(enabled); + } catch (e) { + console.error(e); + } + }, []); + const showError = useCallback((e: any) => { const json = JSON.parse(JSON.stringify(e)); json["constructor.name"] = e?.constructor?.name; @@ -129,6 +164,7 @@ function AuthgearDemo() { }, []); const postConfigure = useCallback(async () => { + await updateBiometricState(); const sessionState = isPlatformWeb() ? authgearWeb.sessionState : authgearCapacitor.sessionState; @@ -144,7 +180,7 @@ function AuthgearDemo() { } setInitialized(true); - }, []); + }, [updateBiometricState]); const configure = useCallback(async () => { setLoading(true); @@ -207,8 +243,48 @@ function AuthgearDemo() { showError(e); } finally { setLoading(false); + await updateBiometricState(); + } + }, [colorScheme, page, showError, showUserInfo, updateBiometricState]); + + const enableBiometric = useCallback(async () => { + setLoading(true); + try { + await authgearCapacitor.enableBiometric(biometricOptions); + } catch (e: unknown) { + showError(e); + } finally { + setLoading(false); + await updateBiometricState(); + } + }, [showError, updateBiometricState]); + + const authenticateBiometric = useCallback(async () => { + setLoading(true); + try { + const { userInfo } = await authgearCapacitor.authenticateBiometric( + biometricOptions + ); + showUserInfo(userInfo); + } catch (e: unknown) { + showError(e); + } finally { + setLoading(false); + await updateBiometricState(); + } + }, [showError, showUserInfo, updateBiometricState]); + + const disableBiometric = useCallback(async () => { + setLoading(true); + try { + await authgearCapacitor.disableBiometric(); + } catch (e: unknown) { + showError(e); + } finally { + setLoading(false); + await updateBiometricState(); } - }, [colorScheme, page, showError, showUserInfo]); + }, [showError, updateBiometricState]); const showAuthTime = useCallback(() => { if (isPlatformWeb()) { @@ -224,7 +300,7 @@ function AuthgearDemo() { } }, []); - const reauthenticate = useCallback(async () => { + const reauthenticateWebOnly = useCallback(async () => { setLoading(true); try { if (isPlatformWeb()) { @@ -260,6 +336,45 @@ function AuthgearDemo() { } }, [showError, colorScheme, showAuthTime]); + const reauthenticate = useCallback(async () => { + setLoading(true); + try { + if (isPlatformWeb()) { + await authgearWeb.refreshIDToken(); + if (!authgearWeb.canReauthenticate()) { + throw new Error( + "canReauthenticate() returns false for the current user" + ); + } + + authgearWeb.startReauthentication({ + redirectURI: REDIRECT_URI_WEB_REAUTH, + }); + } else { + await authgearCapacitor.refreshIDToken(); + if (!authgearCapacitor.canReauthenticate()) { + throw new Error( + "canReauthenticate() returns false for the current user" + ); + } + + await authgearCapacitor.reauthenticate( + { + redirectURI: REDIRECT_URI_CAPACITOR, + colorScheme: + colorScheme === "" ? undefined : (colorScheme as ColorScheme), + }, + biometricOptions + ); + showAuthTime(); + } + } catch (e) { + showError(e); + } finally { + setLoading(false); + } + }, [showError, colorScheme, showAuthTime]); + const openSettings = useCallback(async () => { if (isPlatformWeb()) { authgearWeb.open(WebPage.Settings); @@ -409,6 +524,46 @@ function AuthgearDemo() { [reauthenticate] ); + const onClickReauthenticateWebOnly = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + reauthenticateWebOnly(); + }, + [reauthenticateWebOnly] + ); + + const onClickEnableBiometric = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + enableBiometric(); + }, + [enableBiometric] + ); + + const onClickAuthenticateBiometric = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + authenticateBiometric(); + }, + [authenticateBiometric] + ); + + const onClickDisableBiometric = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + disableBiometric(); + }, + [disableBiometric] + ); + const onClickOpenSettings = useCallback( (e: MouseEvent) => { e.preventDefault(); @@ -525,10 +680,40 @@ function AuthgearDemo() { Re-authenticate + + Re-authenticate (biometric or web) + + {isPlatformWeb() ? null : ( + + Enable biometric + + )} + + Disable biometric + + + Authenticate with biometric + Date: Sat, 13 Jan 2024 14:46:22 +0800 Subject: [PATCH 07/15] Port Android code of biometric --- .../authgear-capacitor/android/build.gradle | 3 +- .../java/com/authgear/capacitor/Authgear.java | 273 ++++++++++++++++++ .../authgear/capacitor/AuthgearPlugin.java | 238 +++++++++++++++ .../authgear/capacitor/BiometricCallback.java | 9 + .../authgear/capacitor/BiometricOptions.java | 17 ++ .../capacitor/KeyNotFoundException.java | 3 + 6 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricCallback.java create mode 100644 packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricOptions.java create mode 100644 packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/KeyNotFoundException.java diff --git a/packages/authgear-capacitor/android/build.gradle b/packages/authgear-capacitor/android/build.gradle index f7373802..44b6752e 100644 --- a/packages/authgear-capacitor/android/build.gradle +++ b/packages/authgear-capacitor/android/build.gradle @@ -53,7 +53,8 @@ dependencies { implementation project(':capacitor-android') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation 'androidx.browser:browser:1.2.0' - implementation "androidx.security:security-crypto:1.1.0-alpha03" + implementation "androidx.biometric:biometric:1.2.0-alpha05" + implementation "androidx.security:security-crypto:1.1.0-alpha06" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/Authgear.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/Authgear.java index c4efeae6..3db50dd9 100644 --- a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/Authgear.java +++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/Authgear.java @@ -5,19 +5,42 @@ import android.content.SharedPreferences; import android.content.pm.PackageInfo; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.provider.Settings; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; +import androidx.biometric.BiometricManager; +import androidx.biometric.BiometricPrompt; import androidx.security.crypto.EncryptedSharedPreferences; import androidx.security.crypto.MasterKey; +import org.json.JSONException; import org.json.JSONObject; import java.nio.charset.Charset; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.PublicKey; import java.security.SecureRandom; +import java.security.Signature; +import java.security.SignatureException; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; class Authgear { + + private static final Charset UTF8 = Charset.forName("UTF-8"); + @Nullable String storageGetItem(Context ctx, String key) throws Exception { SharedPreferences sharedPreferences = this.getSharedPreferences(ctx); @@ -48,6 +71,10 @@ byte[] sha256String(String input) throws Exception { return md.digest(); } + String generateUUID() { + return UUID.randomUUID().toString(); + } + JSONObject getDeviceInfo(Context ctx) throws Exception { String baseOS = ""; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -158,6 +185,252 @@ JSONObject getDeviceInfo(Context ctx) throws Exception { return rootMap; } + int checkBiometricSupported(Context ctx, int flags) throws Exception { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw this.makeBiometricMinimumAPILevelException(); + } + + BiometricManager manager = BiometricManager.from(ctx); + int result = manager.canAuthenticate(flags); + + if (result == BiometricManager.BIOMETRIC_SUCCESS) { + // Further test if the key pair generator can be initialized. + // https://issuetracker.google.com/issues/147374428#comment9 + try { + this.createKeyPairGenerator(this.makeGenerateKeyPairSpec("__test__", flags, true)); + } catch (Exception e) { + // This branch is reachable only when there is a weak face and no strong fingerprints. + // So we treat this situation as BIOMETRIC_ERROR_NONE_ENROLLED. + result = BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED; + // fallthrough + } + } + + return result; + } + + void createBiometricPrivateKey(AppCompatActivity activity, BiometricOptions options, BiometricCallback callback) throws Exception { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw this.makeBiometricMinimumAPILevelException(); + } + + BiometricPrompt.PromptInfo promptInfo = this.buildPromptInfo(options); + KeyGenParameterSpec spec = this.makeGenerateKeyPairSpec( + options.alias, + this.authenticatorTypesToKeyProperties(options.flags), + options.invalidatedByBiometricEnrollment + ); + KeyPair keyPair = this.createKeyPair(spec); + this.signBiometricJWT( + activity, + keyPair, + options.kid, + options.payload, + promptInfo, + callback + ); + } + + void signWithBiometricPrivateKey(AppCompatActivity activity, BiometricOptions options, BiometricCallback callback) throws Exception { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw this.makeBiometricMinimumAPILevelException(); + } + + BiometricPrompt.PromptInfo promptInfo = this.buildPromptInfo(options); + KeyPair keyPair = this.getPrivateKey(options.alias); + this.signBiometricJWT( + activity, + keyPair, + options.kid, + options.payload, + promptInfo, + callback + ); + } + + void removeBiometricPrivateKey(String alias) throws Exception { + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + keyStore.deleteEntry(alias); + } + + @RequiresApi(Build.VERSION_CODES.M) + private KeyGenParameterSpec makeGenerateKeyPairSpec(String alias, int flags, boolean invalidatedByBiometricEnrollment) { + KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder( + alias, KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_VERIFY + ); + builder.setKeySize(2048); + builder.setDigests(KeyProperties.DIGEST_SHA256); + builder.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1); + builder.setUserAuthenticationRequired(true); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + 0, + flags + ); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setInvalidatedByBiometricEnrollment(invalidatedByBiometricEnrollment); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // Samsung Android 12 treats setUnlockedDeviceRequired in a different way. + // If setUnlockedDeviceRequired is true, then the device must be unlocked + // with the same level of security requirement. + // Otherwise, UserNotAuthenticatedException will be thrown when a cryptographic operation is initialized. + // + // The steps to reproduce the bug + // + // - Restart the device + // - Unlock the device with credentials + // - Create a Signature with a PrivateKey with setUnlockedDeviceRequired(true) + // - Call Signature.initSign, UserNotAuthenticatedException will be thrown. + // builder.setUnlockedDeviceRequired(true); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // User confirmation is not needed because the BiometricPrompt itself is a kind of confirmation. + // builder.setUserConfirmationRequired(true) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // User presence requires a physical button which is not our intended use case. + // builder.setUserPresenceRequired(true) + } + + return builder.build(); + } + + @RequiresApi(Build.VERSION_CODES.M) + private KeyPair createKeyPair(KeyGenParameterSpec spec) throws Exception { + return this.createKeyPairGenerator(spec).generateKeyPair(); + } + + @RequiresApi(Build.VERSION_CODES.M) + private KeyPairGenerator createKeyPairGenerator(KeyGenParameterSpec spec) throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore"); + keyPairGenerator.initialize(spec); + return keyPairGenerator; + } + + private KeyPair getPrivateKey(String alias) throws Exception { + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + KeyStore.Entry entry = keyStore.getEntry(alias, null); + if (entry instanceof KeyStore.PrivateKeyEntry) { + KeyStore.PrivateKeyEntry privateKeyEntry = (KeyStore.PrivateKeyEntry) entry; + return new KeyPair(privateKeyEntry.getCertificate().getPublicKey(), privateKeyEntry.getPrivateKey()); + } + throw new KeyNotFoundException(); + } + + private BiometricPrompt.PromptInfo buildPromptInfo( + BiometricOptions options + ) { + BiometricPrompt.PromptInfo.Builder builder = new BiometricPrompt.PromptInfo.Builder(); + builder.setTitle(options.title); + builder.setSubtitle(options.subtitle); + builder.setDescription(options.description); + builder.setAllowedAuthenticators(options.flags); + if ((options.flags & BiometricManager.Authenticators.DEVICE_CREDENTIAL) == 0) { + builder.setNegativeButtonText(options.negativeButtonText); + } + return builder.build(); + } + + private int authenticatorTypesToKeyProperties(int flags) { + int out = 0; + if ((flags & BiometricManager.Authenticators.BIOMETRIC_STRONG) != 0) { + out |= KeyProperties.AUTH_BIOMETRIC_STRONG; + } + if ((flags & BiometricManager.Authenticators.DEVICE_CREDENTIAL) != 0) { + out |= KeyProperties.AUTH_DEVICE_CREDENTIAL; + } + return out; + } + + private void signBiometricJWT(AppCompatActivity activity, KeyPair keyPair, String kid, JSONObject payload, BiometricPrompt.PromptInfo promptInfo, BiometricCallback callback) throws Exception { + JSONObject jwk = this.makeJWK(keyPair, kid); + JSONObject header = this.makeBiometricJWTHeader(jwk); + Signature lockedSignature = this.makeSignature(keyPair.getPrivate()); + BiometricPrompt.CryptoObject cryptoObject = new BiometricPrompt.CryptoObject(lockedSignature); + BiometricPrompt prompt = new BiometricPrompt(activity, new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { + callback.onAuthenticationError(errorCode, errString); + } + + @Override + public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { + Signature signature = result.getCryptoObject().getSignature(); + try { + String jwt = Authgear.this.signJWT(signature, header, payload); + callback.onSuccess(jwt); + } catch (SignatureException e) { + callback.onException(e); + } + } + + @Override + public void onAuthenticationFailed() { + // This callback will be invoked EVERY time the recognition failed. + // So while the prompt is still opened, this callback can be called repetitively. + // Finally, either onAuthenticationError or onAuthenticationSucceeded will be called. + // So this callback is not important to the developer. + } + }); + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(() -> prompt.authenticate(promptInfo, cryptoObject)); + } + + private JSONObject makeJWK(KeyPair keyPair, String kid) throws JSONException { + JSONObject jwk = new JSONObject(); + jwk.put("kid", kid); + PublicKey publicKey = keyPair.getPublic(); + RSAPublicKey rsaPublicKey = (RSAPublicKey) publicKey; + jwk.put("alg", "RS256"); + jwk.put("kty", "RSA"); + jwk.put("n", this.base64URLEncode(rsaPublicKey.getModulus().toByteArray())); + jwk.put("e", this.base64URLEncode(rsaPublicKey.getPublicExponent().toByteArray())); + return jwk; + } + + private JSONObject makeBiometricJWTHeader(JSONObject jwk) throws JSONException { + JSONObject header = new JSONObject(); + header.put("typ", "vnd.authgear.biometric-request"); + header.put("kid", jwk.getString("kid")); + header.put("alg", jwk.getString("alg")); + header.put("jwk", jwk); + return header; + } + + private Signature makeSignature(PrivateKey privateKey) throws Exception { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(privateKey); + return signature; + } + + private String signJWT(Signature signature, JSONObject header, JSONObject payload) throws SignatureException { + String headerJSON = header.toString(); + String payloadJSON = payload.toString(); + String headerString = this.base64URLEncode(headerJSON.getBytes(UTF8)); + String payloadString = this.base64URLEncode(payloadJSON.getBytes(UTF8)); + String strToSign = headerString + "." + payloadString; + signature.update(strToSign.getBytes(UTF8)); + byte[] sig = signature.sign(); + return strToSign + "." + this.base64URLEncode(sig); + } + + private String base64URLEncode(byte[] bytes) { + return Base64.encodeToString(bytes, Base64.NO_WRAP | Base64.URL_SAFE | Base64.NO_PADDING); + } + + private Exception makeBiometricMinimumAPILevelException() { + return new Exception("Biometric authentication requires at least API Level 23"); + } + private SharedPreferences getSharedPreferences(Context ctx) throws Exception { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { MasterKey masterKey = new MasterKey.Builder(ctx) diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java index ea84892b..fc3a12ee 100644 --- a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java +++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java @@ -6,15 +6,22 @@ import android.net.Uri; import androidx.activity.result.ActivityResult; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.biometric.BiometricManager; +import androidx.biometric.BiometricPrompt; import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; +import com.getcapacitor.JSValue; import com.getcapacitor.Plugin; import com.getcapacitor.PluginCall; import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.ActivityCallback; import com.getcapacitor.annotation.CapacitorPlugin; +import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; @CapacitorPlugin(name = "Authgear") @@ -112,6 +119,14 @@ public void getDeviceInfo(PluginCall call) { } } + @PluginMethod + public void generateUUID(PluginCall call) { + String uuid = this.implementation.generateUUID(); + JSObject ret = new JSObject(); + ret.put("uuid", uuid); + call.resolve(ret); + } + @PluginMethod public void openAuthorizeURL(PluginCall call) { String urlString = call.getString("url"); @@ -160,6 +175,229 @@ private void handleOpenURL(PluginCall call, ActivityResult activityResult) { } } + @PluginMethod + public void checkBiometricSupported(PluginCall call) { + JSObject android = call.getObject("android"); + JSONArray constraint = this.jsObjectGetArray(android, "constraint"); + int flags = this.constraintToFlag(constraint); + + Context ctx = this.getContext(); + try { + int result = this.implementation.checkBiometricSupported(ctx, flags); + if (result == BiometricManager.BIOMETRIC_SUCCESS) { + call.resolve(); + } else { + String resultString = this.resultToString(result); + call.reject(resultString, resultString); + } + } catch (Exception e) { + this.reject(call, e); + } + } + + @PluginMethod + public void createBiometricPrivateKey(PluginCall call) { + AppCompatActivity activity = this.getActivity(); + + JSObject payload = call.getObject("payload"); + String kid = call.getString("kid"); + String alias = "com.authgear.keys.biometric." + kid; + JSObject android = call.getObject("android"); + JSONArray constraint = this.jsObjectGetArray(android, "constraint"); + boolean invalidatedByBiometricEnrollment = android.getBool("invalidatedByBiometricEnrollment"); + int flags = this.constraintToFlag(constraint); + String title = android.getString("title"); + String subtitle = android.getString("subtitle"); + String description = android.getString("description"); + String negativeButtonText = android.getString("negativeButtonText"); + + BiometricOptions options = new BiometricOptions(); + options.payload = payload; + options.kid = kid; + options.alias = alias; + options.flags = flags; + options.invalidatedByBiometricEnrollment = invalidatedByBiometricEnrollment; + options.title = title; + options.subtitle = subtitle; + options.description = description; + options.negativeButtonText = negativeButtonText; + + try { + this.implementation.createBiometricPrivateKey( + activity, + options, + new BiometricCallback() { + @Override + public void onSuccess(String jwt) { + JSObject obj = new JSObject(); + obj.put("jwt", jwt); + call.resolve(obj); + } + + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { + call.reject(errString.toString(), AuthgearPlugin.this.errorCodeToString(errorCode)); + } + + @Override + public void onException(Exception e) { + AuthgearPlugin.this.reject(call, e); + } + } + ); + } catch (Exception e) { + this.reject(call, e); + } + } + + @PluginMethod + public void signWithBiometricPrivateKey(PluginCall call) { + AppCompatActivity activity = this.getActivity(); + + JSObject payload = call.getObject("payload"); + String kid = call.getString("kid"); + String alias = "com.authgear.keys.biometric." + kid; + JSObject android = call.getObject("android"); + JSONArray constraint = this.jsObjectGetArray(android, "constraint"); + boolean invalidatedByBiometricEnrollment = android.getBool("invalidatedByBiometricEnrollment"); + int flags = this.constraintToFlag(constraint); + String title = android.getString("title"); + String subtitle = android.getString("subtitle"); + String description = android.getString("description"); + String negativeButtonText = android.getString("negativeButtonText"); + + BiometricOptions options = new BiometricOptions(); + options.payload = payload; + options.kid = kid; + options.alias = alias; + options.flags = flags; + options.invalidatedByBiometricEnrollment = invalidatedByBiometricEnrollment; + options.title = title; + options.subtitle = subtitle; + options.description = description; + options.negativeButtonText = negativeButtonText; + + try { + this.implementation.signWithBiometricPrivateKey( + activity, + options, + new BiometricCallback() { + @Override + public void onSuccess(String jwt) { + JSObject obj = new JSObject(); + obj.put("jwt", jwt); + call.resolve(obj); + } + + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { + call.reject(errString.toString(), AuthgearPlugin.this.errorCodeToString(errorCode)); + } + + @Override + public void onException(Exception e) { + AuthgearPlugin.this.reject(call, e); + } + } + ); + } catch (Exception e) { + this.reject(call, e); + } + } + + @PluginMethod + public void removeBiometricPrivateKey(PluginCall call) { + String kid = call.getString("kid"); + String alias = "com.authgear.keys.biometric." + kid; + + try { + this.implementation.removeBiometricPrivateKey(alias); + call.resolve(); + } catch (Exception e) { + this.reject(call, e); + } + } + + private JSONArray jsObjectGetArray(JSObject obj, String key) { + try { + return obj.getJSONArray(key); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + private int constraintToFlag(JSONArray constraint) { + try { + int flag = 0; + for (int i = 0; i < constraint.length(); i++) { + String c = constraint.getString(i); + if (c.equals("BIOMETRIC_STRONG")) { + flag |= BiometricManager.Authenticators.BIOMETRIC_STRONG; + } + if (c.equals("DEVICE_CREDENTIAL")) { + flag |= BiometricManager.Authenticators.DEVICE_CREDENTIAL; + } + } + return flag; + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + private String resultToString(int result) { + switch (result) { + case BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE: + return "BIOMETRIC_ERROR_HW_UNAVAILABLE"; + case BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED: + return "BIOMETRIC_ERROR_NONE_ENROLLED"; + case BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE: + return "BIOMETRIC_ERROR_NO_HARDWARE"; + case BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED: + return "BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED"; + case BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED: + return "BIOMETRIC_ERROR_UNSUPPORTED"; + case BiometricManager.BIOMETRIC_STATUS_UNKNOWN: + return "BIOMETRIC_STATUS_UNKNOWN"; + default: + return "BIOMETRIC_ERROR_UNKNOWN"; + } + } + + private String errorCodeToString(int errorCode) { + switch (errorCode) { + case BiometricPrompt.ERROR_CANCELED: + return "ERROR_CANCELED"; + case BiometricPrompt.ERROR_HW_NOT_PRESENT: + return "ERROR_HW_NOT_PRESENT"; + case BiometricPrompt.ERROR_HW_UNAVAILABLE: + return "ERROR_HW_UNAVAILABLE"; + case BiometricPrompt.ERROR_LOCKOUT: + return "ERROR_LOCKOUT"; + case BiometricPrompt.ERROR_LOCKOUT_PERMANENT: + return "ERROR_LOCKOUT_PERMANENT"; + case BiometricPrompt.ERROR_NEGATIVE_BUTTON: + return "ERROR_NEGATIVE_BUTTON"; + case BiometricPrompt.ERROR_NO_BIOMETRICS: + return "ERROR_NO_BIOMETRICS"; + case BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL: + return "ERROR_NO_DEVICE_CREDENTIAL"; + case BiometricPrompt.ERROR_NO_SPACE: + return "ERROR_NO_SPACE"; + case BiometricPrompt.ERROR_SECURITY_UPDATE_REQUIRED: + return "ERROR_SECURITY_UPDATE_REQUIRED"; + case BiometricPrompt.ERROR_TIMEOUT: + return "ERROR_TIMEOUT"; + case BiometricPrompt.ERROR_UNABLE_TO_PROCESS: + return "ERROR_UNABLE_TO_PROCESS"; + case BiometricPrompt.ERROR_USER_CANCELED: + return "ERROR_USER_CANCELED"; + case BiometricPrompt.ERROR_VENDOR: + return "ERROR_VENDOR"; + default: + return "ERROR_UNKNOWN"; + } + } + private void reject(PluginCall call, Exception e) { call.reject(e.getMessage(), e.getClass().getName(), e); } diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricCallback.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricCallback.java new file mode 100644 index 00000000..0a9a1ddd --- /dev/null +++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricCallback.java @@ -0,0 +1,9 @@ +package com.authgear.capacitor; + +import androidx.annotation.NonNull; + +interface BiometricCallback { + void onSuccess(String jwt); + void onAuthenticationError(int errorCode, @NonNull CharSequence errString); + void onException(Exception e); +} diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricOptions.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricOptions.java new file mode 100644 index 00000000..21bfeb03 --- /dev/null +++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricOptions.java @@ -0,0 +1,17 @@ +package com.authgear.capacitor; + +import com.getcapacitor.JSObject; + +class BiometricOptions { + JSObject payload; + String kid; + String alias; + int flags; + boolean invalidatedByBiometricEnrollment; + String title; + String subtitle; + String description; + String negativeButtonText; + + BiometricOptions() {} +} diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/KeyNotFoundException.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/KeyNotFoundException.java new file mode 100644 index 00000000..70ca22af --- /dev/null +++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/KeyNotFoundException.java @@ -0,0 +1,3 @@ +package com.authgear.capacitor; + +public class KeyNotFoundException extends Exception {} From 80f6be671c2d920aa5de1d7d7883de0f35f07ec7 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Sat, 13 Jan 2024 14:46:42 +0800 Subject: [PATCH 08/15] Fix error parsing on Android --- packages/authgear-capacitor/src/error.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/authgear-capacitor/src/error.ts b/packages/authgear-capacitor/src/error.ts index d9610fa8..ba6108d1 100644 --- a/packages/authgear-capacitor/src/error.ts +++ b/packages/authgear-capacitor/src/error.ts @@ -46,6 +46,7 @@ const ERROR_SECURITY_UPDATE_REQUIRED = "ERROR_SECURITY_UPDATE_REQUIRED"; const ERROR_USER_CANCELED = "ERROR_USER_CANCELED"; // const ERROR_VENDOR = "ERROR_VENDOR"; +// on iOS // { // "errorMessage": "CANCEL", // "message": "CANCEL", @@ -58,13 +59,19 @@ const ERROR_USER_CANCELED = "ERROR_USER_CANCELED"; // }, // "code": "CANCEL" // } +// +// on Android +// { +// "message": "CANCEL", +// "code": "CANCEL", +// "data": undefined +// } /** * @internal */ export interface PlatformError { message: string; - errorMessage: string; code: string; } @@ -85,13 +92,7 @@ export interface PlatformErrorIOSWithCause extends PlatformError { * @internal */ export function isPlatformError(e: unknown): e is PlatformError { - return ( - typeof e === "object" && - e != null && - "message" in e && - "errorMessage" in e && - "code" in e - ); + return typeof e === "object" && e != null && "message" in e && "code" in e; } /** From 7ea3bef3a3a3acf18893cb1f9e65e48ae6667927 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Wed, 17 Jan 2024 11:10:43 +0800 Subject: [PATCH 09/15] Correct getting started link --- typedoc/capacitor_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typedoc/capacitor_index.md b/typedoc/capacitor_index.md index 1a482ae8..3e0de660 100644 --- a/typedoc/capacitor_index.md +++ b/typedoc/capacitor_index.md @@ -9,6 +9,6 @@ See all the available methods in [CapacitorContainer Reference](./classes/Capaci :::tip Just getting started? -Check out our [Get Started guide](https://docs.authgear.com/get-started/capacitor) to see usage. +Check out our [Get Started guide](https://docs.authgear.com/get-started/native-mobile-app/ionic-sdk) to see usage. ::: From 2413de5a8058ff6bb18c52b672e3aa983e758f64 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Wed, 17 Jan 2024 11:40:18 +0800 Subject: [PATCH 10/15] Add missing descriptions --- packages/authgear-capacitor/src/index.ts | 8 ++++++++ packages/authgear-capacitor/src/storage.ts | 6 ++++++ packages/authgear-core/src/storage.ts | 7 +++++++ packages/authgear-core/src/types.ts | 13 +++++++++++++ packages/authgear-react-native/src/index.ts | 8 +++++++- packages/authgear-react-native/src/storage.ts | 6 ++++++ packages/authgear-web/src/container.ts | 8 +++++++- packages/authgear-web/src/storage.ts | 8 +++++++- 8 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/authgear-capacitor/src/index.ts b/packages/authgear-capacitor/src/index.ts index a3dff663..193c6788 100644 --- a/packages/authgear-capacitor/src/index.ts +++ b/packages/authgear-capacitor/src/index.ts @@ -106,6 +106,14 @@ async function getXDeviceInfo(): Promise { } /** + * CapacitorContainer is the entrypoint of the SDK. + * An instance of a container allows the user to authenticate, reauthenticate, etc. + * + * Every container has a name. + * The default name of a container is `default`. + * If your app supports multi login sessions, you can use multiple containers with different names. + * You are responsible for managing the list of names in this case. + * * @public */ export class CapacitorContainer { diff --git a/packages/authgear-capacitor/src/storage.ts b/packages/authgear-capacitor/src/storage.ts index 0e802e48..09720691 100644 --- a/packages/authgear-capacitor/src/storage.ts +++ b/packages/authgear-capacitor/src/storage.ts @@ -23,6 +23,12 @@ class _PlatformStorageDriver implements _StorageDriver { } /** + * PersistentTokenStorage stores the refresh token in a persistent storage. + * When the app launches again next time, the refresh token is loaded from the persistent storage. + * The user is considered authenticated as long as the refresh token is found. + * However, the validity of the refresh token is not guaranteed. + * You must call fetchUserInfo to ensure the refresh token is still valid. + * * @public */ export class PersistentTokenStorage implements TokenStorage { diff --git a/packages/authgear-core/src/storage.ts b/packages/authgear-core/src/storage.ts index 829f67f8..4aa0e2e8 100644 --- a/packages/authgear-core/src/storage.ts +++ b/packages/authgear-core/src/storage.ts @@ -83,6 +83,13 @@ export class _MemoryStorageDriver implements _StorageDriver { } /** + * TransientTokenStorage stores the refresh token in memory. + * The refresh token is forgotten as soon as the user quits the app, or + * the app was killed by the system. + * When the app launches again next time, no refresh token is found. + * The user is considered unauthenticated. + * This implies the user needs to authenticate over again on every app launch. + * * @public */ export class TransientTokenStorage implements TokenStorage { diff --git a/packages/authgear-core/src/types.ts b/packages/authgear-core/src/types.ts index b53e94a8..05aa569a 100644 --- a/packages/authgear-core/src/types.ts +++ b/packages/authgear-core/src/types.ts @@ -1,4 +1,8 @@ /** + * UserInfo is the result of fetchUserInfo. + * It contains `sub` which is the User ID, + * as well OIDC standard claims like `email`. + * * @public */ export interface UserInfo { @@ -38,6 +42,9 @@ export interface UserInfo { } /** + * ColorScheme represents the color scheme supported by Authgear. + * A colorscheme is either light or dark. Authgear supports both by default. + * * @public */ export enum ColorScheme { @@ -186,6 +193,10 @@ export interface _AnonymousUserPromotionCodeResponse { } /** + * TokenStorage is an interface controlling when refresh tokens are stored. + * Normally you do not need to implement this interface. + * You can use one of those implementations provided by the SDK. + * * @public */ export interface TokenStorage { @@ -221,6 +232,8 @@ export interface _StorageDriver { } /** + * Options for the constructor of a Container. + * * @public */ export interface ContainerOptions { diff --git a/packages/authgear-react-native/src/index.ts b/packages/authgear-react-native/src/index.ts index 7f7063ce..6d465173 100644 --- a/packages/authgear-react-native/src/index.ts +++ b/packages/authgear-react-native/src/index.ts @@ -97,7 +97,13 @@ async function getXDeviceInfo(): Promise { } /** - * React Native Container. + * ReactNativeContainer is the entrypoint of the SDK. + * An instance of a container allows the user to authenticate, reauthenticate, etc. + * + * Every container has a name. + * The default name of a container is `default`. + * If your app supports multi login sessions, you can use multiple containers with different names. + * You are responsible for managing the list of names in this case. * * @public */ diff --git a/packages/authgear-react-native/src/storage.ts b/packages/authgear-react-native/src/storage.ts index 5aff1daa..e4a0b615 100644 --- a/packages/authgear-react-native/src/storage.ts +++ b/packages/authgear-react-native/src/storage.ts @@ -27,6 +27,12 @@ class _PlatformStorageDriver implements _StorageDriver { } /** + * PersistentTokenStorage stores the refresh token in a persistent storage. + * When the app launches again next time, the refresh token is loaded from the persistent storage. + * The user is considered authenticated as long as the refresh token is found. + * However, the validity of the refresh token is not guaranteed. + * You must call fetchUserInfo to ensure the refresh token is still valid. + * * @public */ export class PersistentTokenStorage implements TokenStorage { diff --git a/packages/authgear-web/src/container.ts b/packages/authgear-web/src/container.ts index b9c9053a..d0ded7af 100644 --- a/packages/authgear-web/src/container.ts +++ b/packages/authgear-web/src/container.ts @@ -66,7 +66,13 @@ export interface ConfigureOptions { } /** - * Web Container + * WebContainer is the entrypoint of the SDK. + * An instance of a container allows the user to authenticate, reauthenticate, etc. + * + * Every container has a name. + * The default name of a container is `default`. + * If your app supports multi login sessions, you can use multiple containers with different names. + * You are responsible for managing the list of names in this case. * * @public */ diff --git a/packages/authgear-web/src/storage.ts b/packages/authgear-web/src/storage.ts index 8d8fdff6..71f8f10b 100644 --- a/packages/authgear-web/src/storage.ts +++ b/packages/authgear-web/src/storage.ts @@ -20,7 +20,13 @@ const _localStorageStorageDriver: _StorageDriver = { }; /** - * @internal + * PersistentTokenStorage stores the refresh token in a persistent storage. + * When the app launches again next time, the refresh token is loaded from the persistent storage. + * The user is considered authenticated as long as the refresh token is found. + * However, the validity of the refresh token is not guaranteed. + * You must call fetchUserInfo to ensure the refresh token is still valid. + * + * @public */ export class PersistentTokenStorage implements TokenStorage { private keyMaker: _KeyMaker; From 53f19caaa045b79e6a1d0f4ffab1d6e3d41ae065 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Wed, 17 Jan 2024 11:42:07 +0800 Subject: [PATCH 11/15] Add missing introduction --- typedoc/index.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/typedoc/index.md b/typedoc/index.md index 79a8c130..8555a9c6 100644 --- a/typedoc/index.md +++ b/typedoc/index.md @@ -14,3 +14,8 @@ Read the [Introduction](web/) to get started. [@authgear/react-native](https://www.npmjs.com/package/@authgear/react-native) is the SDK you want to use if your application is written in React Native. Read the [Introduction](react-native/) to get started. + +## Capacitor SDK + +[@authgear/capacitor](https://www.npmjs.com/package/@authgear/capacitor) is the SDK you want to use if your application is written in Ionic with Capacitor. +Read the [Introduction](capacitor/) to get started. From 0595e81a9ec9bb635aa2be6ba4dd21957760c723 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Wed, 17 Jan 2024 12:06:01 +0800 Subject: [PATCH 12/15] Add missing descriptions for biometric related types --- packages/authgear-capacitor/src/types.ts | 95 +++++++++++++++++++++ packages/authgear-react-native/src/types.ts | 95 +++++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/packages/authgear-capacitor/src/types.ts b/packages/authgear-capacitor/src/types.ts index 2fa741c6..61dd26cb 100644 --- a/packages/authgear-capacitor/src/types.ts +++ b/packages/authgear-capacitor/src/types.ts @@ -153,52 +153,147 @@ export interface SettingOptions { } /** + * BiometricLAPolicy configures iOS specific behavior. + * It must be consistent with BiometricAccessConstraintIOS. + * * @public */ export enum BiometricLAPolicy { + /** + * The biometric prompt only prompts for biometric. No fallback to device passcode. + * + * See https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthenticationwithbiometrics + * + * @public + */ deviceOwnerAuthenticationWithBiometrics = "deviceOwnerAuthenticationWithBiometrics", + /** + * The biometric prompt prompts for biometric first, and then fallback to device passcode. + * + * See https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthentication + * + * @public + */ deviceOwnerAuthentication = "deviceOwnerAuthentication", } /** + * BiometricAccessConstraintIOS configures iOS specific behavior. + * It must be consistent with BiometricLAPolicy. + * * @public */ export enum BiometricAccessConstraintIOS { + /** + * The user does not need to set up biometric again when a new finger or face is added or removed. + * + * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/2937191-biometryany + * + * @public + */ BiometricAny = "biometryAny", + /** + * The user needs to set up biometric again when a new finger or face is added or removed. + * + * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/2937192-biometrycurrentset + * + * @public + */ BiometryCurrentSet = "biometryCurrentSet", + /** + * The user can either use biometric or device code to authenticate. + * + * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/1392879-userpresence + * + * @public + */ UserPresence = "userPresence", } /** + * iOS specific options for biometric authentication. + * * @public */ export interface BiometricOptionsIOS { + /** + * See https://developer.apple.com/documentation/localauthentication/lacontext/1514176-evaluatepolicy#parameters + * + * @public + */ localizedReason: string; constraint: BiometricAccessConstraintIOS; policy: BiometricLAPolicy; } /** + * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators + * * @public */ export enum BiometricAccessConstraintAndroid { + /** + * The user can use Class 3 biometric to authenticate. + * + * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators#BIOMETRIC_STRONG() + * + * @public + */ BiometricStrong = "BIOMETRIC_STRONG", + /** + * The user can either use biometric or device code to authenticate. + * + * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators#DEVICE_CREDENTIAL() + * + * @public + */ DeviceCredential = "DEVICE_CREDENTIAL", } /** + * Android specific options for biometric authentication. + * * @public */ export interface BiometricOptionsAndroid { + /** + * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getTitle() + * + * @public + */ title: string; + /** + * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getSubtitle() + * + * @public + */ subtitle: string; + /** + * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getDescription() + * + * @public + */ description: string; + /** + * https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getNegativeButtonText() + * + * @public + */ negativeButtonText: string; constraint: BiometricAccessConstraintAndroid[]; + /** + * The user needs to set up biometric again when a new biometric is enrolled or all enrolled biometrics are removed. + * + * See https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec#isInvalidatedByBiometricEnrollment() + * + * @public + */ invalidatedByBiometricEnrollment: boolean; } /** + * BiometricOptions is options for biometric authentication. + * * @public */ export interface BiometricOptions { diff --git a/packages/authgear-react-native/src/types.ts b/packages/authgear-react-native/src/types.ts index 03051f61..93234079 100644 --- a/packages/authgear-react-native/src/types.ts +++ b/packages/authgear-react-native/src/types.ts @@ -201,52 +201,147 @@ export interface SettingOptions { } /** + * BiometricLAPolicy configures iOS specific behavior. + * It must be consistent with BiometricAccessConstraintIOS. + * * @public */ export enum BiometricLAPolicy { + /** + * The biometric prompt only prompts for biometric. No fallback to device passcode. + * + * See https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthenticationwithbiometrics + * + * @public + */ deviceOwnerAuthenticationWithBiometrics = "deviceOwnerAuthenticationWithBiometrics", + /** + * The biometric prompt prompts for biometric first, and then fallback to device passcode. + * + * See https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthentication + * + * @public + */ deviceOwnerAuthentication = "deviceOwnerAuthentication", } /** + * BiometricAccessConstraintIOS configures iOS specific behavior. + * It must be consistent with BiometricLAPolicy. + * * @public */ export enum BiometricAccessConstraintIOS { + /** + * The user does not need to set up biometric again when a new finger or face is added or removed. + * + * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/2937191-biometryany + * + * @public + */ BiometricAny = "biometryAny", + /** + * The user needs to set up biometric again when a new finger or face is added or removed. + * + * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/2937192-biometrycurrentset + * + * @public + */ BiometryCurrentSet = "biometryCurrentSet", + /** + * The user can either use biometric or device code to authenticate. + * + * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/1392879-userpresence + * + * @public + */ UserPresence = "userPresence", } /** + * iOS specific options for biometric authentication. + * * @public */ export interface BiometricOptionsIOS { + /** + * See https://developer.apple.com/documentation/localauthentication/lacontext/1514176-evaluatepolicy#parameters + * + * @public + */ localizedReason: string; constraint: BiometricAccessConstraintIOS; policy: BiometricLAPolicy; } /** + * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators + * * @public */ export enum BiometricAccessConstraintAndroid { + /** + * The user can use Class 3 biometric to authenticate. + * + * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators#BIOMETRIC_STRONG() + * + * @public + */ BiometricStrong = "BIOMETRIC_STRONG", + /** + * The user can either use biometric or device code to authenticate. + * + * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators#DEVICE_CREDENTIAL() + * + * @public + */ DeviceCredential = "DEVICE_CREDENTIAL", } /** + * Android specific options for biometric authentication. + * * @public */ export interface BiometricOptionsAndroid { + /** + * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getTitle() + * + * @public + */ title: string; + /** + * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getSubtitle() + * + * @public + */ subtitle: string; + /** + * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getDescription() + * + * @public + */ description: string; + /** + * https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getNegativeButtonText() + * + * @public + */ negativeButtonText: string; constraint: BiometricAccessConstraintAndroid[]; + /** + * The user needs to set up biometric again when a new biometric is enrolled or all enrolled biometrics are removed. + * + * See https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec#isInvalidatedByBiometricEnrollment() + * + * @public + */ invalidatedByBiometricEnrollment: boolean; } /** + * BiometricOptions is options for biometric authentication. + * * @public */ export interface BiometricOptions { From c835efff949bff7e91e3f0e7d4d6d28d6956efe2 Mon Sep 17 00:00:00 2001 From: Tung Wu Date: Wed, 17 Jan 2024 13:05:33 +0800 Subject: [PATCH 13/15] Update lock file after updated package versions --- example/capacitor/package-lock.json | 10 +++++----- package-lock.json | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/example/capacitor/package-lock.json b/example/capacitor/package-lock.json index b3eacd1b..612d3961 100644 --- a/example/capacitor/package-lock.json +++ b/example/capacitor/package-lock.json @@ -51,11 +51,11 @@ } }, "../../packages/authgear-capacitor": { - "name": "authgear-sdk-capacitor", - "version": "2.2.0", + "name": "@authgear/capacitor", + "version": "2.3.2", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.2.0", + "@authgear/core": "2.3.2", "@capacitor/android": "^5.0.0", "@capacitor/core": "^5.0.0", "@capacitor/ios": "^5.0.0" @@ -66,10 +66,10 @@ }, "../../packages/authgear-web": { "name": "@authgear/web", - "version": "2.2.0", + "version": "2.3.2", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.2.0", + "@authgear/core": "2.3.2", "core-js-pure": "3.22.7" } }, diff --git a/package-lock.json b/package-lock.json index 5e2be874..5593b001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14557,10 +14557,10 @@ }, "packages/authgear-capacitor": { "name": "@authgear/capacitor", - "version": "2.3.1", + "version": "2.3.2", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.3.1", + "@authgear/core": "2.3.2", "@capacitor/android": "^5.0.0", "@capacitor/core": "^5.0.0", "@capacitor/ios": "^5.0.0" @@ -14571,7 +14571,7 @@ }, "packages/authgear-core": { "name": "@authgear/core", - "version": "2.3.1", + "version": "2.3.2", "license": "Apache-2.0", "devDependencies": { "base64-arraybuffer": "1.0.2", @@ -14581,10 +14581,10 @@ }, "packages/authgear-react-native": { "name": "@authgear/react-native", - "version": "2.3.1", + "version": "2.3.2", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.3.1", + "@authgear/core": "2.3.2", "@types/react-native": "0.69.1", "core-js-pure": "3.22.7" }, @@ -14594,10 +14594,10 @@ }, "packages/authgear-web": { "name": "@authgear/web", - "version": "2.3.1", + "version": "2.3.2", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.3.1", + "@authgear/core": "2.3.2", "core-js-pure": "3.22.7" } } @@ -14613,7 +14613,7 @@ "@authgear/capacitor": { "version": "file:packages/authgear-capacitor", "requires": { - "@authgear/core": "2.3.1", + "@authgear/core": "2.3.2", "@capacitor/android": "^5.0.0", "@capacitor/core": "^5.0.0", "@capacitor/ios": "^5.0.0" @@ -14630,7 +14630,7 @@ "@authgear/react-native": { "version": "file:packages/authgear-react-native", "requires": { - "@authgear/core": "2.3.1", + "@authgear/core": "2.3.2", "@types/react-native": "0.69.1", "core-js-pure": "3.22.7" } @@ -14638,7 +14638,7 @@ "@authgear/web": { "version": "file:packages/authgear-web", "requires": { - "@authgear/core": "2.3.1", + "@authgear/core": "2.3.2", "core-js-pure": "3.22.7" } }, From c23f4681804baa0d208f223bcfb8be83c00bffbe Mon Sep 17 00:00:00 2001 From: Tung Wu Date: Wed, 17 Jan 2024 13:45:27 +0800 Subject: [PATCH 14/15] Add instructions to run capacitor app --- example/capacitor/README.md | 35 ++++++++++++++++++++++++++++++++++ example/capacitor/package.json | 4 +++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 example/capacitor/README.md diff --git a/example/capacitor/README.md b/example/capacitor/README.md new file mode 100644 index 00000000..174a9951 --- /dev/null +++ b/example/capacitor/README.md @@ -0,0 +1,35 @@ +# Ionic Capacitor Example App + +Example app of ionic app using capacitor. + +## Project config + +We need the following redirect uris to work: + +``` +http://localhost:8100/reauth-redirect +http://localhost:8100/oauth-redirect +com.authgear.exampleapp.capacitor://host/path +https://localhost +capacitor://localhost +``` + +## Run the app + +Before running the app, build the latest sdk at project root. + +```sh +npm i +npm run build +``` + +Then, in this directory, run the exmaple app. + +```sh +cd example/capacitor +npm i +# Run it in ios device +npm run run-ios +# OR, run it in android device +npm run run-android +``` diff --git a/example/capacitor/package.json b/example/capacitor/package.json index 3293ef59..c988b12f 100644 --- a/example/capacitor/package.json +++ b/example/capacitor/package.json @@ -8,7 +8,9 @@ "build": "tsc && vite build", "preview": "vite preview", "test.unit": "vitest", - "lint": "eslint" + "lint": "eslint", + "run-ios": "cap run ios", + "run-android": "cap run android" }, "dependencies": { "@authgear/capacitor": "../../packages/authgear-capacitor", From ec561c53eb2560779daa2ec9afa56c376a17048f Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Wed, 17 Jan 2024 18:52:42 +0800 Subject: [PATCH 15/15] Work around a simulator issue related to biometric --- .../authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift index 957f87e2..b0f8da14 100644 --- a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift +++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift @@ -350,6 +350,10 @@ import Capacitor // https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/protecting_keys_with_the_secure_enclave flags.insert(.privateKeyUsage) + #if targetEnvironment(simulator) + // On Xcode 15.2, iPhone 15 iOS 17.2, using any of these flags will result in + // NSOSStatusErrorDomain code=-25293 message="Key generation failed" + #else switch constraint { case "biometryAny": flags.insert(.biometryAny) @@ -360,6 +364,7 @@ import Capacitor default: break } + #endif guard let accessControl = SecAccessControlCreateWithFlags( kCFAllocatorDefault,