UNPKG

@alore/auth-react-native-sdk

Version:
714 lines (621 loc) 20.7 kB
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'); } } }