Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 465
feat(clerk-expo): Introduce support for LocalAuth with LocalCredentials#3663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
5d0aeefd93018ff194c8bc2a1425d01daf80a068404215762e80425decce02d84edb49e946474a57936f7a5af170cb780e388877af323216dfccb21a1849922b021d2086e892ff0337dc7fddbf4c2a0f89e83bbe76706a09b7a40935e13266b6308dFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@clerk/clerk-expo": minor | ||
| --- | ||
| Introduce support for LocalAuthentication with `useLocalCredentials`. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -12,3 +12,4 @@ export { | ||
| } from '@clerk/clerk-react'; | ||
| export * from './useOAuth'; | ||
| export * from './useLocalCredentials'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { useLocalCredentials } from './useLocalCredentials'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void>; | ||
| hasCredentials: boolean; | ||
| userOwnsCredentials: boolean | null; | ||
| clearCredentials: () => Promise<void>; | ||
| authenticate: () => Promise<SignInResource>; | ||
| 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 }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<BiometricType | null>(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 => { | ||
panteliselef marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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)]); | ||
panteliselef marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| setHasLocalAuthCredentials(false); | ||
| setUserOwnsCredentials(false); | ||
| }; | ||
| const authenticate = async (): Promise<SignInResource> => { | ||
| 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, | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import type { LocalCredentialsReturn } from './shared'; | ||
| import { LocalCredentialsInitValues } from './shared'; | ||
| export const useLocalCredentials = (): LocalCredentialsReturn => { | ||
| return LocalCredentialsInitValues; | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ Should we have something like
isPlatformSupportedinLocalCredentialsInitValuesin order to be easier to check if the hook can be used on specific platforms like Web or a device that does not has any biometric support, as now it seems you will have to check ifbiometryTypeis null which is not so clear if you don't take a look at the codeThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For devices that do not support biometric, android and iOS fallback to PIN, and if a pin does not exist then
biometricTypewill be null.with biometricType you get the available authenticator and it is useful mostly for UI purposes. Displaying a Face ID or a touch ID icon for example.
Maybe simply changing the name to
supportedBiometricTypewould do ?We don't wanna indicate that the device can use biometric, but instead if we can use local authentication (biometric or not) in order to store credentials.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was mostly referring for platforms that this will not work at all like web,the biometric support was just an example as I didn't know if this fallbacks to pin