diff --git a/.changeset/rude-poets-beam.md b/.changeset/rude-poets-beam.md new file mode 100644 index 00000000000..1d5fef5332c --- /dev/null +++ b/.changeset/rude-poets-beam.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-expo": minor +--- + +Introduce support for LocalAuthentication with `useLocalCredentials`. diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 diff --git a/package-lock.json b/package-lock.json index 44891a28b5d..c65a3798b76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27443,6 +27443,18 @@ "expo": "*" } }, + "node_modules/expo-local-authentication": { + "version": "13.8.0", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-13.8.0.tgz", + "integrity": "sha512-h0YA7grVdo3834AS70EUCsalaXrrEnoq+yTvIhRTxiPmzWxUv7rNo5ff+XsIEYNElKPmT/wh/xPV1yo3l3fhGg==", + "dev": true, + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "1.10.3", "dev": true, @@ -27554,6 +27566,15 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-secure-store": { + "version": "12.8.1", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-12.8.1.tgz", + "integrity": "sha512-Ju3jmkHby4w7rIzdYAt9kQyQ7HhHJ0qRaiQOInknhOLIltftHjEgF4I1UmzKc7P5RCfGNmVbEH729Pncp/sHXQ==", + "dev": true, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-web-browser": { "version": "12.8.2", "dev": true, @@ -53983,6 +54004,8 @@ "@types/react": "*", "@types/react-dom": "*", "expo-auth-session": "^5.4.0", + "expo-local-authentication": "^13.5.0", + "expo-secure-store": "^12.4.0", "expo-web-browser": "^12.8.2", "react-native": "^0.73.9", "typescript": "*" @@ -53992,10 +54015,20 @@ }, "peerDependencies": { "expo-auth-session": ">=5", + "expo-local-authentication": ">=13.5.0", + "expo-secure-store": ">=12.4.0", "expo-web-browser": ">=12.5.0", "react": ">=18", "react-dom": ">=18", "react-native": ">=0.73" + }, + "peerDependenciesMeta": { + "expo-local-authentication": { + "optional": true + }, + "expo-secure-store": { + "optional": true + } } }, "packages/expo/node_modules/@jest/types": { diff --git a/packages/expo/package.json b/packages/expo/package.json index eec787d91e4..08ddb6c495f 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -65,17 +65,29 @@ "@types/react": "*", "@types/react-dom": "*", "expo-auth-session": "^5.4.0", + "expo-local-authentication": "^13.5.0", + "expo-secure-store": "^12.4.0", "expo-web-browser": "^12.8.2", "react-native": "^0.73.9", "typescript": "*" }, "peerDependencies": { "expo-auth-session": ">=5", + "expo-local-authentication": ">=13.5.0", + "expo-secure-store": ">=12.4.0", "expo-web-browser": ">=12.5.0", "react-native": ">=0.73", "react": ">=18", "react-dom": ">=18" }, + "peerDependenciesMeta": { + "expo-secure-store": { + "optional": true + }, + "expo-local-authentication": { + "optional": true + } + }, "engines": { "node": ">=18.17.0" }, diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 969e644bfb9..d5bd5f1a91b 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -12,3 +12,4 @@ export { } from '@clerk/clerk-react'; export * from './useOAuth'; +export * from './useLocalCredentials'; diff --git a/packages/expo/src/hooks/useLocalCredentials/index.tsx b/packages/expo/src/hooks/useLocalCredentials/index.tsx new file mode 100644 index 00000000000..3548abc9206 --- /dev/null +++ b/packages/expo/src/hooks/useLocalCredentials/index.tsx @@ -0,0 +1 @@ +export { useLocalCredentials } from './useLocalCredentials'; diff --git a/packages/expo/src/hooks/useLocalCredentials/shared.ts b/packages/expo/src/hooks/useLocalCredentials/shared.ts new file mode 100644 index 00000000000..53f13242e45 --- /dev/null +++ b/packages/expo/src/hooks/useLocalCredentials/shared.ts @@ -0,0 +1,37 @@ +import type { SignInResource } from '@clerk/types'; + +type LocalCredentials = { + /** + * The identifier of the credentials to be stored on the device. It can be a username, email, phone number, etc. + */ + identifier?: string; + /** + * The password for the identifier to be stored on the device. If an identifier already exists on the device passing only password would update the password for the stored identifier. + */ + password: string; +}; + +type BiometricType = 'fingerprint' | 'face-recognition'; + +type LocalCredentialsReturn = { + setCredentials: (creds: LocalCredentials) => Promise; + hasCredentials: boolean; + userOwnsCredentials: boolean | null; + clearCredentials: () => Promise; + authenticate: () => Promise; + biometricType: BiometricType | null; +}; + +const LocalCredentialsInitValues: LocalCredentialsReturn = { + setCredentials: () => Promise.resolve(), + hasCredentials: false, + userOwnsCredentials: null, + clearCredentials: () => Promise.resolve(), + // @ts-expect-error Initial value cannot return what the type expects + authenticate: () => Promise.resolve({}), + biometricType: null, +}; + +export { LocalCredentialsInitValues }; + +export type { LocalCredentials, BiometricType, LocalCredentialsReturn }; diff --git a/packages/expo/src/hooks/useLocalCredentials/useLocalCredentials.ts b/packages/expo/src/hooks/useLocalCredentials/useLocalCredentials.ts new file mode 100644 index 00000000000..472cb04cd85 --- /dev/null +++ b/packages/expo/src/hooks/useLocalCredentials/useLocalCredentials.ts @@ -0,0 +1,216 @@ +import { useClerk, useSignIn, useUser } from '@clerk/clerk-react'; +import type { SignInResource } from '@clerk/types'; +import { AuthenticationType, isEnrolledAsync, supportedAuthenticationTypesAsync } from 'expo-local-authentication'; +import { + deleteItemAsync, + getItem, + getItemAsync, + setItemAsync, + WHEN_PASSCODE_SET_THIS_DEVICE_ONLY, +} from 'expo-secure-store'; +import { useEffect, useState } from 'react'; + +import { errorThrower } from '../../utils'; +import type { BiometricType, LocalCredentials, LocalCredentialsReturn } from './shared'; + +const useEnrolledBiometric = () => { + const [isEnrolled, setIsEnrolled] = useState(false); + + useEffect(() => { + let ignore = false; + + void isEnrolledAsync().then(res => { + if (ignore) { + return; + } + setIsEnrolled(res); + }); + + return () => { + ignore = true; + }; + }, []); + + return isEnrolled; +}; + +const useAuthenticationType = () => { + const [authenticationType, setAuthenticationType] = useState(null); + + useEffect(() => { + let ignore = false; + + void supportedAuthenticationTypesAsync().then(numericTypes => { + if (ignore) { + return; + } + if (numericTypes.length === 0) { + return; + } + + if ( + numericTypes.includes(AuthenticationType.IRIS) || + numericTypes.includes(AuthenticationType.FACIAL_RECOGNITION) + ) { + setAuthenticationType('face-recognition'); + } else { + setAuthenticationType('fingerprint'); + } + }); + + return () => { + ignore = true; + }; + }, []); + + return authenticationType; +}; + +const useUserOwnsCredentials = ({ storeKey }: { storeKey: string }) => { + const { user } = useUser(); + const [userOwnsCredentials, setUserOwnsCredentials] = useState(false); + + const getUserCredentials = (storedIdentifier: string | null): boolean => { + if (!user || !storedIdentifier) { + return false; + } + + const identifiers = [ + user.emailAddresses.map(e => e.emailAddress), + user.phoneNumbers.map(p => p.phoneNumber), + ].flat(); + + if (user.username) { + identifiers.push(user.username); + } + return identifiers.includes(storedIdentifier); + }; + + useEffect(() => { + let ignore = false; + getItemAsync(storeKey) + .catch(() => null) + .then(res => { + if (ignore) { + return; + } + setUserOwnsCredentials(getUserCredentials(res)); + }); + + return () => { + ignore = true; + }; + }, [storeKey, user]); + + return [userOwnsCredentials, setUserOwnsCredentials] as const; +}; + +/** + * Exposes utilities that allow for storing and accessing an identifier, and it's password securely on the device. + * In order to access the stored credentials, the end user will be prompted to verify themselves via biometrics. + */ +export const useLocalCredentials = (): LocalCredentialsReturn => { + const { isLoaded, signIn } = useSignIn(); + const { publishableKey } = useClerk(); + + const key = `__clerk_local_auth_${publishableKey}_identifier`; + const pkey = `__clerk_local_auth_${publishableKey}_password`; + const [hasLocalAuthCredentials, setHasLocalAuthCredentials] = useState(!!getItem(key)); + const [userOwnsCredentials, setUserOwnsCredentials] = useUserOwnsCredentials({ storeKey: key }); + const hasEnrolledBiometric = useEnrolledBiometric(); + const authenticationType = useAuthenticationType(); + + const biometricType = hasEnrolledBiometric ? authenticationType : null; + + const setCredentials = async (creds: LocalCredentials) => { + if (!(await isEnrolledAsync())) { + return; + } + + if (creds.identifier && !creds.password) { + return errorThrower.throw( + `useLocalCredentials: setCredentials() A password is required when specifying an identifier.`, + ); + } + + if (creds.identifier) { + await setItemAsync(key, creds.identifier); + } + + const storedIdentifier = await getItemAsync(key).catch(() => null); + + if (!storedIdentifier) { + return errorThrower.throw( + `useLocalCredentials: setCredentials() an identifier should already be set in order to update its password.`, + ); + } + + setHasLocalAuthCredentials(true); + await setItemAsync(pkey, creds.password, { + keychainAccessible: WHEN_PASSCODE_SET_THIS_DEVICE_ONLY, + requireAuthentication: true, + }); + }; + + const clearCredentials = async () => { + await Promise.all([deleteItemAsync(key), deleteItemAsync(pkey)]); + setHasLocalAuthCredentials(false); + setUserOwnsCredentials(false); + }; + + const authenticate = async (): Promise => { + if (!isLoaded) { + return errorThrower.throw( + `useLocalCredentials: authenticate() Clerk has not loaded yet. Wait for clerk to load before calling this function`, + ); + } + const identifier = await getItemAsync(key).catch(() => null); + if (!identifier) { + return errorThrower.throw(`useLocalCredentials: authenticate() the identifier could not be found`); + } + const password = await getItemAsync(pkey).catch(() => null); + + if (!password) { + return errorThrower.throw(`useLocalCredentials: authenticate() cannot retrieve a password for ${identifier}`); + } + + return signIn.create({ + strategy: 'password', + identifier, + password, + }); + }; + + return { + /** + * Stores the provided credentials on the device if the device has enrolled biometrics. + * The end user needs to have a passcode set in order for the credentials to be stored, and those credentials will be removed if the passcode gets removed. + * @param credentials A [`LocalCredentials`](#localcredentials) object. + * @return A promise that will reject if value cannot be stored on the device. + */ + setCredentials, + /** + * A Boolean that indicates if there are any credentials stored on the device. + */ + hasCredentials: hasLocalAuthCredentials, + /** + * A Boolean that indicates if the stored credentials belong to the signed in uer. When there is no signed-in user the value will always be `false`. + */ + userOwnsCredentials, + /** + * Removes the stored credentials from the device. + * @return A promise that will reject if value cannot be deleted from the device. + */ + clearCredentials, + /** + * Attempts to read the stored credentials and creates a sign in attempt with the password strategy. + * @return A promise with a SignInResource if the stored credentials were accessed, otherwise the promise will reject. + */ + authenticate, + /** + * Indicates the supported enrolled biometric authenticator type. + * Can be `facial-recognition`, `fingerprint` or null. + */ + biometricType, + }; +}; diff --git a/packages/expo/src/hooks/useLocalCredentials/useLocalCredentials.web.ts b/packages/expo/src/hooks/useLocalCredentials/useLocalCredentials.web.ts new file mode 100644 index 00000000000..2ff0a589abd --- /dev/null +++ b/packages/expo/src/hooks/useLocalCredentials/useLocalCredentials.web.ts @@ -0,0 +1,6 @@ +import type { LocalCredentialsReturn } from './shared'; +import { LocalCredentialsInitValues } from './shared'; + +export const useLocalCredentials = (): LocalCredentialsReturn => { + return LocalCredentialsInitValues; +};