Skip to content

Commit ca631fd

Browse files
authored
feat(clerk-expo): Introduce support for LocalAuth with LocalCredentials (clerk#3663)
1 parent 773b88f commit ca631fd

File tree

9 files changed

+311
-0
lines changed

9 files changed

+311
-0
lines changed

.changeset/rude-poets-beam.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/clerk-expo": minor
3+
---
4+
5+
Introduce support for LocalAuthentication with `useLocalCredentials`.

.husky/pre-commit

100755100644
File mode changed.

package-lock.json

+33
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/expo/package.json

+12
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,29 @@
6565
"@types/react": "*",
6666
"@types/react-dom": "*",
6767
"expo-auth-session": "^5.4.0",
68+
"expo-local-authentication": "^13.5.0",
69+
"expo-secure-store": "^12.4.0",
6870
"expo-web-browser": "^12.8.2",
6971
"react-native": "^0.73.9",
7072
"typescript": "*"
7173
},
7274
"peerDependencies": {
7375
"expo-auth-session": ">=5",
76+
"expo-local-authentication": ">=13.5.0",
77+
"expo-secure-store": ">=12.4.0",
7478
"expo-web-browser": ">=12.5.0",
7579
"react-native": ">=0.73",
7680
"react": ">=18",
7781
"react-dom": ">=18"
7882
},
83+
"peerDependenciesMeta": {
84+
"expo-secure-store": {
85+
"optional": true
86+
},
87+
"expo-local-authentication": {
88+
"optional": true
89+
}
90+
},
7991
"engines": {
8092
"node": ">=18.17.0"
8193
},

packages/expo/src/hooks/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export {
1212
} from '@clerk/clerk-react';
1313

1414
export * from './useOAuth';
15+
export * from './useLocalCredentials';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useLocalCredentials } from './useLocalCredentials';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { SignInResource } from '@clerk/types';
2+
3+
type LocalCredentials = {
4+
/**
5+
* The identifier of the credentials to be stored on the device. It can be a username, email, phone number, etc.
6+
*/
7+
identifier?: string;
8+
/**
9+
* 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.
10+
*/
11+
password: string;
12+
};
13+
14+
type BiometricType = 'fingerprint' | 'face-recognition';
15+
16+
type LocalCredentialsReturn = {
17+
setCredentials: (creds: LocalCredentials) => Promise<void>;
18+
hasCredentials: boolean;
19+
userOwnsCredentials: boolean | null;
20+
clearCredentials: () => Promise<void>;
21+
authenticate: () => Promise<SignInResource>;
22+
biometricType: BiometricType | null;
23+
};
24+
25+
const LocalCredentialsInitValues: LocalCredentialsReturn = {
26+
setCredentials: () => Promise.resolve(),
27+
hasCredentials: false,
28+
userOwnsCredentials: null,
29+
clearCredentials: () => Promise.resolve(),
30+
// @ts-expect-error Initial value cannot return what the type expects
31+
authenticate: () => Promise.resolve({}),
32+
biometricType: null,
33+
};
34+
35+
export { LocalCredentialsInitValues };
36+
37+
export type { LocalCredentials, BiometricType, LocalCredentialsReturn };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { useClerk, useSignIn, useUser } from '@clerk/clerk-react';
2+
import type { SignInResource } from '@clerk/types';
3+
import { AuthenticationType, isEnrolledAsync, supportedAuthenticationTypesAsync } from 'expo-local-authentication';
4+
import {
5+
deleteItemAsync,
6+
getItem,
7+
getItemAsync,
8+
setItemAsync,
9+
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
10+
} from 'expo-secure-store';
11+
import { useEffect, useState } from 'react';
12+
13+
import { errorThrower } from '../../utils';
14+
import type { BiometricType, LocalCredentials, LocalCredentialsReturn } from './shared';
15+
16+
const useEnrolledBiometric = () => {
17+
const [isEnrolled, setIsEnrolled] = useState(false);
18+
19+
useEffect(() => {
20+
let ignore = false;
21+
22+
void isEnrolledAsync().then(res => {
23+
if (ignore) {
24+
return;
25+
}
26+
setIsEnrolled(res);
27+
});
28+
29+
return () => {
30+
ignore = true;
31+
};
32+
}, []);
33+
34+
return isEnrolled;
35+
};
36+
37+
const useAuthenticationType = () => {
38+
const [authenticationType, setAuthenticationType] = useState<BiometricType | null>(null);
39+
40+
useEffect(() => {
41+
let ignore = false;
42+
43+
void supportedAuthenticationTypesAsync().then(numericTypes => {
44+
if (ignore) {
45+
return;
46+
}
47+
if (numericTypes.length === 0) {
48+
return;
49+
}
50+
51+
if (
52+
numericTypes.includes(AuthenticationType.IRIS) ||
53+
numericTypes.includes(AuthenticationType.FACIAL_RECOGNITION)
54+
) {
55+
setAuthenticationType('face-recognition');
56+
} else {
57+
setAuthenticationType('fingerprint');
58+
}
59+
});
60+
61+
return () => {
62+
ignore = true;
63+
};
64+
}, []);
65+
66+
return authenticationType;
67+
};
68+
69+
const useUserOwnsCredentials = ({ storeKey }: { storeKey: string }) => {
70+
const { user } = useUser();
71+
const [userOwnsCredentials, setUserOwnsCredentials] = useState(false);
72+
73+
const getUserCredentials = (storedIdentifier: string | null): boolean => {
74+
if (!user || !storedIdentifier) {
75+
return false;
76+
}
77+
78+
const identifiers = [
79+
user.emailAddresses.map(e => e.emailAddress),
80+
user.phoneNumbers.map(p => p.phoneNumber),
81+
].flat();
82+
83+
if (user.username) {
84+
identifiers.push(user.username);
85+
}
86+
return identifiers.includes(storedIdentifier);
87+
};
88+
89+
useEffect(() => {
90+
let ignore = false;
91+
getItemAsync(storeKey)
92+
.catch(() => null)
93+
.then(res => {
94+
if (ignore) {
95+
return;
96+
}
97+
setUserOwnsCredentials(getUserCredentials(res));
98+
});
99+
100+
return () => {
101+
ignore = true;
102+
};
103+
}, [storeKey, user]);
104+
105+
return [userOwnsCredentials, setUserOwnsCredentials] as const;
106+
};
107+
108+
/**
109+
* Exposes utilities that allow for storing and accessing an identifier, and it's password securely on the device.
110+
* In order to access the stored credentials, the end user will be prompted to verify themselves via biometrics.
111+
*/
112+
export const useLocalCredentials = (): LocalCredentialsReturn => {
113+
const { isLoaded, signIn } = useSignIn();
114+
const { publishableKey } = useClerk();
115+
116+
const key = `__clerk_local_auth_${publishableKey}_identifier`;
117+
const pkey = `__clerk_local_auth_${publishableKey}_password`;
118+
const [hasLocalAuthCredentials, setHasLocalAuthCredentials] = useState(!!getItem(key));
119+
const [userOwnsCredentials, setUserOwnsCredentials] = useUserOwnsCredentials({ storeKey: key });
120+
const hasEnrolledBiometric = useEnrolledBiometric();
121+
const authenticationType = useAuthenticationType();
122+
123+
const biometricType = hasEnrolledBiometric ? authenticationType : null;
124+
125+
const setCredentials = async (creds: LocalCredentials) => {
126+
if (!(await isEnrolledAsync())) {
127+
return;
128+
}
129+
130+
if (creds.identifier && !creds.password) {
131+
return errorThrower.throw(
132+
`useLocalCredentials: setCredentials() A password is required when specifying an identifier.`,
133+
);
134+
}
135+
136+
if (creds.identifier) {
137+
await setItemAsync(key, creds.identifier);
138+
}
139+
140+
const storedIdentifier = await getItemAsync(key).catch(() => null);
141+
142+
if (!storedIdentifier) {
143+
return errorThrower.throw(
144+
`useLocalCredentials: setCredentials() an identifier should already be set in order to update its password.`,
145+
);
146+
}
147+
148+
setHasLocalAuthCredentials(true);
149+
await setItemAsync(pkey, creds.password, {
150+
keychainAccessible: WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
151+
requireAuthentication: true,
152+
});
153+
};
154+
155+
const clearCredentials = async () => {
156+
await Promise.all([deleteItemAsync(key), deleteItemAsync(pkey)]);
157+
setHasLocalAuthCredentials(false);
158+
setUserOwnsCredentials(false);
159+
};
160+
161+
const authenticate = async (): Promise<SignInResource> => {
162+
if (!isLoaded) {
163+
return errorThrower.throw(
164+
`useLocalCredentials: authenticate() Clerk has not loaded yet. Wait for clerk to load before calling this function`,
165+
);
166+
}
167+
const identifier = await getItemAsync(key).catch(() => null);
168+
if (!identifier) {
169+
return errorThrower.throw(`useLocalCredentials: authenticate() the identifier could not be found`);
170+
}
171+
const password = await getItemAsync(pkey).catch(() => null);
172+
173+
if (!password) {
174+
return errorThrower.throw(`useLocalCredentials: authenticate() cannot retrieve a password for ${identifier}`);
175+
}
176+
177+
return signIn.create({
178+
strategy: 'password',
179+
identifier,
180+
password,
181+
});
182+
};
183+
184+
return {
185+
/**
186+
* Stores the provided credentials on the device if the device has enrolled biometrics.
187+
* 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.
188+
* @param credentials A [`LocalCredentials`](#localcredentials) object.
189+
* @return A promise that will reject if value cannot be stored on the device.
190+
*/
191+
setCredentials,
192+
/**
193+
* A Boolean that indicates if there are any credentials stored on the device.
194+
*/
195+
hasCredentials: hasLocalAuthCredentials,
196+
/**
197+
* 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`.
198+
*/
199+
userOwnsCredentials,
200+
/**
201+
* Removes the stored credentials from the device.
202+
* @return A promise that will reject if value cannot be deleted from the device.
203+
*/
204+
clearCredentials,
205+
/**
206+
* Attempts to read the stored credentials and creates a sign in attempt with the password strategy.
207+
* @return A promise with a SignInResource if the stored credentials were accessed, otherwise the promise will reject.
208+
*/
209+
authenticate,
210+
/**
211+
* Indicates the supported enrolled biometric authenticator type.
212+
* Can be `facial-recognition`, `fingerprint` or null.
213+
*/
214+
biometricType,
215+
};
216+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { LocalCredentialsReturn } from './shared';
2+
import { LocalCredentialsInitValues } from './shared';
3+
4+
export const useLocalCredentials = (): LocalCredentialsReturn => {
5+
return LocalCredentialsInitValues;
6+
};

0 commit comments

Comments
 (0)