@alore/auth-react-native-sdk
Version:
React Native SDK for Alore Auth
714 lines (621 loc) • 20.7 kB
text/typescript
import base64url from 'base64url';
import { randomBytes } from 'crypto';
import { Platform } from 'react-native';
import { Passkey } from 'react-native-passkey';
import {
PasskeyCreateRequest,
PasskeyCreateResult,
PasskeyGetRequest,
PasskeyGetResult,
} from 'react-native-passkey/lib/typescript/PasskeyTypes';
import { AloreAuthError, ErrorTypes } from './AloreAuthError';
import { AuthMachineContext, AuthProviderConfig } from './types';
const DEFAULT_URL = 'https://alpha-api.bealore.com/v1';
type FetchWithProgressiveBackoffConfig = {
maxAttempts?: number;
initialDelay?: number;
};
export class AloreAuth {
protected readonly aloreBaseUrl: string;
protected readonly emailTemplate: string;
protected readonly clientId: string;
constructor(clientId: string, aloreBaseUrl?: string, config?: AuthProviderConfig) {
if (!clientId) throw new AloreAuthError(ErrorTypes.CLIENT_ID_REQUIRED, 'Client ID is required');
this.clientId = clientId;
this.aloreBaseUrl = aloreBaseUrl || DEFAULT_URL;
this.emailTemplate = config?.emailTemplate || '';
}
services = {
healthCheck: async () => {
try {
await this.verifyBackendStatus();
} catch (error) {
throw new AloreAuthError(ErrorTypes.SERVER_DOWN, 'Server is down');
}
return { data: true };
},
finishPasskeyAuth: async (
context: AuthMachineContext,
event: {
type: 'FINISH_PASSKEY_LOGIN';
payload: {
passkeyAuth: PasskeyCreateResult;
};
},
) => {
const { passkeyAuth } = event.payload;
const response = await this.fetchWithProgressiveBackoff(`/auth/v1/login-passkey-finish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
passkeyAuth,
sessionId: context.sessionId,
rpOrigin: context.authProviderConfigs?.rpDomain,
}),
});
const data = await response.json();
if (response.ok) return data;
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
userInputLoginPasskey: async (
context: AuthMachineContext,
event: {
type: 'USER_INPUT_PASSKEY_LOGIN';
payload: {
RCRPublicKey: { publicKey: PasskeyCreateRequest };
};
},
) => {
if (!Passkey.isSupported()) {
throw new AloreAuthError(ErrorTypes.PASSKEY_NOT_SUPPORTED, 'Passkey not supported');
}
const publicKey = event.payload.RCRPublicKey?.publicKey;
const { isFirstLogin } = context;
if (!publicKey) {
throw new AloreAuthError(
ErrorTypes.PUBLIC_KEY_CREATION_OPTIONS_UNDEFINED,
'PublicKeyCredentialCreationOptions is undefined',
);
}
const blob = new Uint8Array(randomBytes(32));
const loginData: PasskeyGetRequest = {
...publicKey,
extensions: {
largeBlob: isFirstLogin
? {
write: blob,
}
: { read: true },
prf: { eval: { first: new TextEncoder().encode('Alore') } },
},
};
const result = await Passkey.get(loginData).catch((error) => {
throw new AloreAuthError(ErrorTypes.PASSKEY_GET_ERROR, error.message);
});
let loginResultJson: PasskeyGetResult;
if (typeof result === 'string') {
// @ts-ignore
loginResultJson = JSON.parse(result);
} else {
loginResultJson = result;
}
// @ts-ignore
const clientExtensionResults = loginResultJson?.clientExtensionResults;
// @ts-ignore
const prfWritten = !!clientExtensionResults?.prf?.results?.first;
// @ts-ignore
const blobWritten = !!clientExtensionResults?.largeBlob?.written;
// @ts-ignore
const blobRead = clientExtensionResults?.largeBlob?.blob;
let secretFromCredential: Uint8Array | undefined;
if (prfWritten && clientExtensionResults?.prf?.results?.first) {
// Use PRF result
secretFromCredential = new Uint8Array(clientExtensionResults?.prf?.results?.first);
} else if (isFirstLogin && blobWritten) {
// Use newly written largeBlob
secretFromCredential = blob;
} else if (!isFirstLogin && blobRead) {
// Use stored largeBlob from authenticator
secretFromCredential = new Uint8Array(blobRead);
}
// TODO: add libtss
if (!secretFromCredential) {
throw new AloreAuthError(
ErrorTypes.CREATE_SECRET_FROM_CREDENTIAL_FAILED,
'Failed to create secret from credential',
);
}
return {
...loginResultJson,
secret: Buffer.from(secretFromCredential).toString('base64'),
};
},
startPasskeyAuth: async (
context: AuthMachineContext,
event: {
type: 'START_PASSKEY_LOGIN';
payload?: {
email: string;
};
},
) => {
let url = `/auth/v1/login-passkey?rp_origin=${context.authProviderConfigs?.rpDomain}`;
const startAuthResponse = await this.fetchWithProgressiveBackoff(url);
const data = await startAuthResponse.json();
if (startAuthResponse.ok) return data;
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
userInputRegisterPasskey: async (
context: AuthMachineContext,
event: {
type: 'USER_INPUT_PASSKEY_REGISTER';
payload: {
CCRPublicKey: { publicKey: PasskeyGetRequest };
email?: string;
nickname?: string;
};
},
) => {
if (!Passkey.isSupported()) {
throw new AloreAuthError(ErrorTypes.PASSKEY_NOT_SUPPORTED, 'Passkey not supported');
}
const { CCRPublicKey } = event.payload;
let { email, nickname } = event.payload;
email = email || context.userId;
nickname = nickname || context.userId;
const publicKey = CCRPublicKey?.publicKey;
if (!publicKey) {
throw new AloreAuthError(
ErrorTypes.PUBLIC_KEY_CREATION_OPTIONS_UNDEFINED,
'PublicKeyCredentialCreationOptions is undefined',
);
}
const createData: PasskeyCreateRequest = {
...publicKey,
rp: {
...publicKey.rp,
id: publicKey.rp.id!,
},
user: {
...publicKey.user,
name: email,
displayName: nickname,
},
extensions: {
prf: {
eval: {
first: new TextEncoder().encode('Alore'),
},
},
largeBlob: {
supported: Platform.OS === 'ios',
},
},
authenticatorSelection: {
requireResidentKey: true,
residentKey: 'required',
userVerification: 'required',
},
};
const result = await Passkey.create(createData).catch((error) => {
throw new AloreAuthError(ErrorTypes.PASSKEY_CREATE_ERROR, error.message);
});
let registerResultJson: PasskeyCreateResult;
if (typeof result === 'string') {
// @ts-ignore
registerResultJson = JSON.parse(result);
} else {
registerResultJson = result;
}
// @ts-ignore
const clientExtensionResults = registerResultJson?.clientExtensionResults;
// @ts-ignore
const prfSupported = !!clientExtensionResults?.prf?.enabled;
// @ts-ignore
const largeBlobSupported = !!clientExtensionResults?.largeBlob?.supported;
if (!prfSupported && Platform.OS !== 'ios') {
throw new AloreAuthError(
ErrorTypes.PRF_EXTENSION_NOT_SUPPORTED,
'PRF extension not supported',
);
} else if (!largeBlobSupported && Platform.OS === 'ios') {
throw new AloreAuthError(
ErrorTypes.LARGE_BLOB_EXTENSION_NOT_SUPPORTED,
'Large blob extension not supported',
);
}
return registerResultJson;
},
startRegisterPasskey: async (
context: AuthMachineContext,
event: {
type: 'START_PASSKEY_REGISTER';
payload: {
device: string;
email?: string;
nickname?: string;
};
},
) => {
const { email, nickname, device } = event.payload;
const response = await this.fetchWithProgressiveBackoff(
`/auth/v1/account-registration-passkey`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userEmail: email || null,
userNickname: nickname || null,
userDevice: device,
rpOrigin: context.authProviderConfigs?.rpDomain,
}),
},
);
const data = await response.json();
if (response.ok) return data;
if (response.status === 409) {
throw new AloreAuthError(
ErrorTypes.USER_ALREADY_EXISTS,
data.message || data.error || data,
);
}
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
finishRegisterPasskey: async (
context: AuthMachineContext,
event: {
type: 'FINISH_PASSKEY_REGISTER';
payload: {
email?: string;
nickname?: string;
passkeyRegistration: PasskeyGetResult;
device: string;
};
},
) => {
const { email, nickname, device, passkeyRegistration } = event.payload;
const response = await this.fetchWithProgressiveBackoff(
`/auth/v1/account-registration-passkey-finish`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userEmail: email || null,
userNickname: nickname || null,
userDevice: device,
passkeyRegistration: {
id: passkeyRegistration.id,
rawId: base64url.fromBase64(passkeyRegistration.rawId),
response: {
attestationObject: base64url.fromBase64(
passkeyRegistration.response.attestationObject,
),
clientDataJSON: base64url.fromBase64(passkeyRegistration.response.clientDataJSON),
},
type: passkeyRegistration.type,
},
sessionId: context.sessionId,
rpOrigin: context.authProviderConfigs?.rpDomain,
userId: context.userId,
}),
},
);
const data = await response.json();
if (response.ok) return data;
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
completeRegistration: async (
_: AuthMachineContext,
event: {
type: 'COMPLETE_REGISTRATION';
payload: {
email: string;
nickname: string;
passwordHash: string;
device: string;
};
},
) => {
const { email, nickname, passwordHash, device } = event.payload;
const response = await this.fetchWithProgressiveBackoff(`/auth/v1/account-registration`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
nickname: nickname || null,
passwordHash,
device,
}),
});
const data = await response.json();
if (response.ok) return data;
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
sendConfirmationEmail: async (
_: AuthMachineContext,
event:
| {
type: 'RESEND_CODE';
payload: {
email: string;
nickname?: string | undefined;
device?: string | undefined;
passwordHash?: string | undefined;
isForgeClaim?: boolean | undefined;
locale?: string | undefined;
};
}
| {
type: 'SEND_REGISTRATION_EMAIL';
payload: {
email: string;
nickname?: string;
isForgeClaim?: boolean;
locale?: string;
};
},
) => {
const { email, nickname, locale } = event.payload;
const searchParams = new URLSearchParams();
if (locale) {
searchParams.append('locale', locale);
}
if (this.emailTemplate) {
searchParams.append('template', this.emailTemplate);
}
const url = searchParams.toString()
? `/auth/v1/confirmation-email?${searchParams.toString()}`
: '/auth/v1/confirmation-email';
const response = await this.fetchWithProgressiveBackoff(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
nickname: nickname || null,
locale,
}),
});
const data = await response.json();
if (response.ok) return data;
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
verifyEmail: async (
context: AuthMachineContext,
event: {
type: 'VERIFY_EMAIL';
payload: {
secureCode: string;
};
},
) => {
const { secureCode } = event.payload;
const response = await this.fetchWithProgressiveBackoff(
`/auth/v1/registration-code-verification`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
emailCode: secureCode,
sessionId: context.sessionId,
}),
},
);
if (!response.ok) {
const data = await response.json();
if (data.message.includes('code is wrong')) {
throw new AloreAuthError(ErrorTypes.WRONG_2FA_CODE, 'The given code is wrong');
}
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
} else {
return {};
}
},
retrieveSalt: async (
_: AuthMachineContext,
event: {
type: 'NEXT';
payload: {
email: string;
};
},
) => {
const { email } = event.payload;
const response = await this.fetchWithProgressiveBackoff(`/auth/v1/salt/${email}`, {
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (response.ok) return data;
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
verifyLogin: async (
_: AuthMachineContext,
event:
| {
type: 'VERIFY_LOGIN';
payload: {
email: string;
device: string;
passwordHash: string;
isForgeClaim?: boolean | undefined;
locale?: string | undefined;
};
}
| {
type: 'RESEND_CODE';
payload: {
email: string;
nickname?: string | undefined;
device?: string | undefined;
passwordHash?: string | undefined;
isForgeClaim?: boolean | undefined;
locale?: string | undefined;
};
},
) => {
const { email, passwordHash, device, locale } = event.payload;
const searchParams = new URLSearchParams();
if (locale) {
searchParams.append('locale', locale);
}
if (this.emailTemplate) {
searchParams.append('template', this.emailTemplate);
}
const url = searchParams.toString()
? `/auth/v1/login-verification?${searchParams.toString()}`
: '/auth/v1/login-verification';
const response = await this.fetchWithProgressiveBackoff(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
passwordHash,
device,
}),
});
const data = await response.json();
if (!response.ok) {
if (response.status === 403) {
return { active2fa: data };
}
if (data?.error?.includes('2FA') || data?.error?.includes('device')) {
return { error: data?.error };
}
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data?.message || data?.error || data);
} else {
return data;
}
},
verifyEmail2fa: async (
context: AuthMachineContext,
event: {
type: 'VERIFY_EMAIL_2FA';
payload: {
email: string;
secureCode: string;
passwordHash: string;
};
},
) => {
const { email, passwordHash, secureCode } = event.payload;
const response = await this.fetchWithProgressiveBackoff(`/auth/v1/email-2fa-verification`, {
method: 'POST',
body: JSON.stringify({
email,
passwordHash,
emailCode: secureCode,
sessionId: context.sessionId,
}),
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (response.ok) return data;
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
inactivateUser: async (context: AuthMachineContext) => {
const response = await this.fetchWithProgressiveBackoff(
`/users/inactivate/${context.sessionUser?.id}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${context.sessionUser?.accessToken}` || '',
},
},
);
const data = await response.json();
if (response.ok) return data;
throw new AloreAuthError(ErrorTypes.FAILED_TO_FETCH, data.message || data.error || data);
},
};
public async fetchWithProgressiveBackoff(
// eslint-disable-next-line no-undef
url: RequestInfo | URL,
// eslint-disable-next-line no-undef
options?: RequestInit,
config?: FetchWithProgressiveBackoffConfig,
) {
const { maxAttempts = 3, initialDelay = 200 } = config || {};
let attempt = 0;
let delayValue = initialDelay;
let errorType: ErrorTypes = ErrorTypes.FAILED_TO_FETCH;
let errorMessage = '';
// eslint-disable-next-line no-undef
const init: RequestInit = {
...options,
headers: {
...options?.headers,
'X-CLIENT-ID': this.clientId,
'CF-Connecting-IP': '127.0.0.1',
},
};
while (attempt < maxAttempts) {
if (attempt > 0) {
// eslint-disable-next-line no-await-in-loop, no-promise-executor-return, no-loop-func
await this.delay(delayValue);
delayValue *= 2;
}
attempt += 1;
try {
// eslint-disable-next-line no-await-in-loop
const response = await fetch(`${this.aloreBaseUrl}${url}`, init);
if (response.status === 429) {
errorType = ErrorTypes.TOO_MANY_REQUESTS;
errorMessage = response.statusText;
break;
}
if (response.ok || attempt === maxAttempts || response.status !== 500) {
return response;
}
} catch (error: any) {
console.error(error);
if (attempt >= maxAttempts) {
errorMessage =
error.message || 'Connection refused, the backend is probably not running.';
await this.verifyBackendStatus().catch(() => {
console.error('Health check error');
errorMessage = 'Server is down';
});
} else if (attempt < maxAttempts) {
console.error(`Attempt ${attempt} failed, retrying in ${delayValue}ms...`);
}
}
}
throw new AloreAuthError(errorType, errorMessage);
}
private async delay(ms: number) {
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, ms));
}
private async verifyBackendStatus() {
try {
const url = `${this.aloreBaseUrl}/health-check`;
const basePath = url.match(/(https?:\/\/[^\//]+)/);
if (!basePath || basePath.length < 2) {
throw new Error('FAILED_TO_FETCH');
}
const res = await fetch(`${basePath[1]}/health-check`);
if (!res.ok) {
throw new Error('FAILED_TO_FETCH');
}
} catch {
throw new Error('SERVER_DOWN');
}
}
}