@clerk/expo-passkeys
Version:
Passkeys library to be used with Clerk for expo
196 lines (175 loc) • 6.43 kB
text/typescript
import { Platform } from 'react-native';
import type {
AuthenticationResponseJSON,
CredentialReturn,
PublicKeyCredentialCreationOptionsWithoutExtensions,
PublicKeyCredentialRequestOptionsWithoutExtensions,
PublicKeyCredentialWithAuthenticatorAssertionResponse,
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RegistrationResponseJSON,
SerializedPublicKeyCredentialCreationOptions,
SerializedPublicKeyCredentialRequestOptions,
} from './ClerkExpoPasskeys.types';
import ClerkExpoPasskeys from './ClerkExpoPasskeysModule';
import {
arrayBufferToBase64Url,
base64urlToArrayBuffer,
ClerkWebAuthnError,
encodeBase64Url,
mapNativeErrorToClerkWebAuthnErrorCode,
toArrayBuffer,
} from './utils';
const makeSerializedCreateResponse = (
publicCredential: RegistrationResponseJSON,
): PublicKeyCredentialWithAuthenticatorAttestationResponse => ({
id: publicCredential.id,
rawId: base64urlToArrayBuffer(publicCredential.rawId),
response: {
getTransports: () => publicCredential?.response?.transports as string[],
attestationObject: base64urlToArrayBuffer(publicCredential.response.attestationObject),
clientDataJSON: base64urlToArrayBuffer(publicCredential.response.clientDataJSON),
},
type: publicCredential.type,
authenticatorAttachment: publicCredential.authenticatorAttachment || null,
toJSON: () => publicCredential,
});
export async function create(
publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions,
): Promise<CredentialReturn<PublicKeyCredentialWithAuthenticatorAttestationResponse>> {
if (!publicKey || !publicKey.rp.id) {
throw new Error('Invalid public key or RpID');
}
const createOptions: SerializedPublicKeyCredentialCreationOptions = {
rp: { id: publicKey.rp.id, name: publicKey.rp.name },
user: {
id: encodeBase64Url(toArrayBuffer(publicKey.user.id)),
displayName: publicKey.user.displayName,
name: publicKey.user.name,
},
pubKeyCredParams: publicKey.pubKeyCredParams,
challenge: encodeBase64Url(toArrayBuffer(publicKey.challenge)),
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: true,
residentKey: 'required',
userVerification: 'required',
},
excludeCredentials: publicKey.excludeCredentials.map(c => ({
type: 'public-key',
id: encodeBase64Url(toArrayBuffer(c.id)),
})),
};
const createPasskeyModule = Platform.select({
android: async () => ClerkExpoPasskeys.create(JSON.stringify(createOptions)),
ios: async () =>
ClerkExpoPasskeys.create(
createOptions.challenge,
createOptions.rp.id,
createOptions.user.id,
createOptions.user.displayName,
),
default: null,
});
if (!createPasskeyModule) {
throw new Error('Platform not supported');
}
try {
const response = await createPasskeyModule();
return {
publicKeyCredential: makeSerializedCreateResponse(typeof response === 'string' ? JSON.parse(response) : response),
error: null,
};
} catch (error: any) {
return {
publicKeyCredential: null,
error: mapNativeErrorToClerkWebAuthnErrorCode(error.code, error.message, 'create'),
};
}
}
const makeSerializedGetResponse = (
publicKeyCredential: AuthenticationResponseJSON,
): PublicKeyCredentialWithAuthenticatorAssertionResponse => {
return {
type: publicKeyCredential.type,
id: publicKeyCredential.id,
rawId: base64urlToArrayBuffer(publicKeyCredential.rawId),
authenticatorAttachment: publicKeyCredential?.authenticatorAttachment || null,
response: {
clientDataJSON: base64urlToArrayBuffer(publicKeyCredential.response.clientDataJSON),
authenticatorData: base64urlToArrayBuffer(publicKeyCredential.response.authenticatorData),
signature: base64urlToArrayBuffer(publicKeyCredential.response.signature),
userHandle: publicKeyCredential?.response.userHandle
? base64urlToArrayBuffer(publicKeyCredential?.response.userHandle)
: null,
},
toJSON: () => publicKeyCredential,
};
};
export async function get({
publicKeyOptions,
}: {
publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions;
}): Promise<CredentialReturn<PublicKeyCredentialWithAuthenticatorAssertionResponse>> {
if (!publicKeyOptions) {
throw new Error('publicKeyCredential has not been provided');
}
const serializedPublicCredential: SerializedPublicKeyCredentialRequestOptions = {
...publicKeyOptions,
// @ts-expect-error FIXME
challenge: arrayBufferToBase64Url(publicKeyOptions.challenge),
};
const getPasskeyModule = Platform.select({
android: async () => ClerkExpoPasskeys.get(JSON.stringify(serializedPublicCredential)),
ios: async () => ClerkExpoPasskeys.get(serializedPublicCredential.challenge, serializedPublicCredential.rpId),
default: null,
});
if (!getPasskeyModule) {
return {
publicKeyCredential: null,
error: new ClerkWebAuthnError('Platform is not supported', { code: 'passkey_not_supported' }),
};
}
try {
const response = await getPasskeyModule();
return {
publicKeyCredential: makeSerializedGetResponse(typeof response === 'string' ? JSON.parse(response) : response),
error: null,
};
} catch (error: any) {
return {
publicKeyCredential: null,
error: mapNativeErrorToClerkWebAuthnErrorCode(error.code, error.message, 'get'),
};
}
}
const ANDROID_9 = 28;
const IOS_15 = 15;
export function isSupported() {
if (Platform.OS === 'android') {
return Platform.Version >= ANDROID_9;
}
if (Platform.OS === 'ios') {
return parseInt(Platform.Version, 10) > IOS_15;
}
return false;
}
// FIX:The autofill function has been implemented for iOS only, but the pop-up is not showing up.
// This seems to be an issue with Expo that we haven't been able to resolve yet.
// Further investigation and possibly reaching out to Expo support may be necessary.
// async function autofill(): Promise<AuthenticationResponseJSON | null> {
// if (Platform.OS === 'android') {
// throw new Error('Not supported');
// } else if (Platform.OS === 'ios') {
// throw new Error('Not supported');
// } else {
// throw new Error('Not supported');
// }
// }
export const passkeys = {
create,
get,
isSupported,
isAutoFillSupported: () => {
throw new Error('Not supported');
},
};