diff --git a/.env.example b/.env.example index 462926c..1bb92b7 100644 --- a/.env.example +++ b/.env.example @@ -24,5 +24,6 @@ VITE_ANALYTICS_SUBDOMAIN_URL=https://metrics.string.xyz VITE_IPFS_GATEWAY=https://nftstorage.link/ipfs/ VITE_IPFS_CID=bafybeieqi56p6vlxofj6wkoort2m5r72ajhtikpzo53wnyze5isvn34fze + VITE_STRING_API_KEY=str... diff --git a/src/lib/services/apiClient.service.ts b/src/lib/services/apiClient.service.ts index 8925044..57cce3a 100644 --- a/src/lib/services/apiClient.service.ts +++ b/src/lib/services/apiClient.service.ts @@ -82,6 +82,23 @@ export function createApiClient({ baseUrl, apiKey }: ApiClientOptions): ApiClien } } + async function requestDeviceVerification(nonce: string, signature: string, visitor: VisitorData) { + const body = { + nonce, + signature, + fingerprint: visitor, + }; + + try { + await httpClient.post(`/users/verify-device`, body, { + headers: authHeaders, + }); + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + async function loginUser(nonce: string, signature: string, visitor?: VisitorData, bypassDeviceCheck = false) { const body = { nonce, @@ -144,6 +161,47 @@ export function createApiClient({ baseUrl, apiKey }: ApiClientOptions): ApiClien } } + async function getDeviceStatus(nonce: string, signature: string, visitor: VisitorData) { + const body = { + nonce, + signature, + fingerprint: visitor, + }; + + try { + const { data } = await httpClient.post<{ status: string }>(`/users/device-status`, body, { + headers: authHeaders, + }); + + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function getUserEmailPreview(nonce: string, signature: string) { + // The request body takes a fingerprint but it does not matter what it is + const body = { + nonce, + signature, + fingerprint: { + visitorId: "", + requestId: "", + } + } + try { + const { data } = await httpClient.post<{email: string}>(`/users/preview-email`, body, { + headers: authHeaders, + }); + + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + async function getQuote(payload: TransactionRequest) { try { const request = () => httpClient.post(`/quotes`, payload); @@ -205,10 +263,13 @@ export function createApiClient({ baseUrl, apiKey }: ApiClientOptions): ApiClien createUser, updateUser, requestEmailVerification, + requestDeviceVerification, loginUser, refreshToken, logoutUser, getUserStatus, + getDeviceStatus, + getUserEmailPreview, getQuote, transact, setWalletAddress, @@ -220,10 +281,13 @@ export interface ApiClient { createUser: (nonce: string, signature: string, visitor?: VisitorData) => Promise; updateUser: (userId: string, userUpdate: UserUpdate) => Promise; requestEmailVerification: (userId: string, email: string) => Promise; + requestDeviceVerification: (nonce: string, signature: string, visitor: VisitorData) => Promise; loginUser: (nonce: string, signature: string, visitor?: VisitorData, bypassDeviceCheck?: boolean) => Promise; refreshToken: (walletAddress: string) => Promise; logoutUser: () => Promise; getUserStatus: (userId: string) => Promise<{ status: string }>; + getDeviceStatus: (nonce: string, signature: string, visitor: VisitorData) => Promise<{ status: string }>; + getUserEmailPreview: (nonce: string, signature: string) => Promise<{ email: string }>; getQuote: (request: TransactionRequest) => Promise; transact: (request: ExecutionRequest) => Promise; setWalletAddress: (walletAddress: string) => void; diff --git a/src/lib/services/auth.service.ts b/src/lib/services/auth.service.ts index a6cad1d..1ad7ffa 100644 --- a/src/lib/services/auth.service.ts +++ b/src/lib/services/auth.service.ts @@ -36,13 +36,16 @@ export function createAuthService({ apiClient, locationService, bypassDeviceChec } } - const retryLogin = async () => { + const getPreviousSignature = async () => { // TODO: Use refresh token instead // TODO: Modify refresh token endpoint to verify visitor data if (!previousAttempt.signature) throw { code: "UNAUTHORIZED" }; const visitorData = await locationService.getVisitorData(); - return login(previousAttempt.nonce, previousAttempt.signature, visitorData); + + if (!visitorData) throw new Error("cannot get device data"); + + return { nonce: previousAttempt.nonce, signature: previousAttempt.signature, visitor: visitorData} }; @@ -78,7 +81,8 @@ export function createAuthService({ apiClient, locationService, bypassDeviceChec return { loginOrCreateUser, fetchLoggedInUser, - retryLogin, + requestSignature, + getPreviousSignature, logout }; } @@ -92,6 +96,7 @@ export interface AuthServiceParams { export interface AuthService { loginOrCreateUser: (walletAddress: string) => Promise<{ user: User }>; fetchLoggedInUser: (walletAddress: string) => Promise; - retryLogin: () => Promise<{ user: User }>; + requestSignature: (userAddress: string, encodedMessage: string) => Promise; + getPreviousSignature: () => Promise<{nonce: string, signature: string, visitor: VisitorData}>; logout: () => Promise; } \ No newline at end of file diff --git a/src/lib/services/events.service.ts b/src/lib/services/events.service.ts index 7f98503..c410d36 100644 --- a/src/lib/services/events.service.ts +++ b/src/lib/services/events.service.ts @@ -1,34 +1,39 @@ import type { StringPay, StringPayload } from "../StringPay"; import type { ApiClient, ExecutionRequest, TransactionRequest, Quote, PaymentInfo, User, UserUpdate } from "./apiClient.service"; -import type { AuthService } from "./auth.service"; import type { LocationService } from "./location.service"; import type { QuoteService } from "./quote.service"; +import type { AuthService } from "./auth.service"; const CHANNEL = "STRING_PAY"; export enum Events { - LOAD_PAYLOAD = "load_payload", - IFRAME_READY = "ready", - IFRAME_RESIZE = "resize", - IFRAME_CLOSE = "close", - REQUEST_AUTHORIZE_USER = "request_authorize_user", - RECEIVE_AUTHORIZE_USER = "receive_authorize_user", - REQUEST_RETRY_LOGIN = "request_retry_login", - RECEIVE_RETRY_LOGIN = "receive_retry_login", - REQUEST_UPDATE_USER = 'request_update_user', - RECEIVE_UPDATE_USER = 'receive_update_user', - REQUEST_EMAIL_VERIFICATION = "request_email_verification", - RECEIVE_EMAIL_VERIFICATION = "receive_email_verification", + LOAD_PAYLOAD = "load_payload", + IFRAME_READY = "ready", + IFRAME_RESIZE = "resize", + IFRAME_CLOSE = "close", + REQUEST_AUTHORIZE_USER = "request_authorize_user", + RECEIVE_AUTHORIZE_USER = "receive_authorize_user", + REQUEST_UPDATE_USER = 'request_update_user', + RECEIVE_UPDATE_USER = 'receive_update_user', + REQUEST_EMAIL_VERIFICATION = "request_email_verification", + RECEIVE_EMAIL_VERIFICATION = "receive_email_verification", + REQUEST_EMAIL_PREVIEW = "request_email_preview", + RECEIVE_EMAIL_PREVIEW = "receive_email_preview", + REQUEST_DEVICE_VERIFICATION = "request_device_verification", + RECEIVE_DEVICE_VERIFICATION = "receive_device_verification", REQUEST_CONFIRM_TRANSACTION = "request_confirm_transaction", RECEIVE_CONFIRM_TRANSACTION = "receive_confirm_transaction", - REQUEST_QUOTE_START = "request_quote_start", - QUOTE_CHANGED = "quote_changed", - REQUEST_QUOTE_STOP = "request_quote_stop", + REQUEST_QUOTE_START = "request_quote_start", + QUOTE_CHANGED = "quote_changed", + REQUEST_QUOTE_STOP = "request_quote_stop", } const eventHandlers: Record void> = {}; export function createEventsService(iframeUrl: string, authService: AuthService, quoteService: QuoteService, apiClient: ApiClient, locationService: LocationService) { + let emailCheckInterval: NodeJS.Timer | undefined; + let deviceCheckInterval: NodeJS.Timer | undefined; + const sendEvent = (frame: HTMLIFrameElement, eventName: string, data?: T, error?: any) => { if (!frame) { err("a frame was not provided to sendEvent"); @@ -72,6 +77,10 @@ export function createEventsService(iframeUrl: string, authService: AuthService, function cleanup() { unregisterEvents(); + + clearInterval(emailCheckInterval); + clearInterval(deviceCheckInterval); + const stringPay = window.StringPay; if (stringPay) { @@ -82,16 +91,17 @@ export function createEventsService(iframeUrl: string, authService: AuthService, } } - // - eventHandlers[Events.IFRAME_READY] = onIframeReady; - eventHandlers[Events.IFRAME_CLOSE] = onIframeClose; - eventHandlers[Events.IFRAME_RESIZE] = onIframeResize; - eventHandlers[Events.REQUEST_AUTHORIZE_USER] = onAuthorizeUser; - eventHandlers[Events.REQUEST_RETRY_LOGIN] = onRetryLogin; - eventHandlers[Events.REQUEST_UPDATE_USER] = onUpdateUser; - eventHandlers[Events.REQUEST_EMAIL_VERIFICATION] = onEmailVerification; - eventHandlers[Events.REQUEST_QUOTE_START] = onQuoteStart; - eventHandlers[Events.REQUEST_QUOTE_STOP] = onQuoteStop; + // Hook event handlers to the events + eventHandlers[Events.IFRAME_READY] = onIframeReady; + eventHandlers[Events.IFRAME_CLOSE] = onIframeClose; + eventHandlers[Events.IFRAME_RESIZE] = onIframeResize; + eventHandlers[Events.REQUEST_AUTHORIZE_USER] = onAuthorizeUser; + eventHandlers[Events.REQUEST_UPDATE_USER] = onUpdateUser; + eventHandlers[Events.REQUEST_EMAIL_VERIFICATION] = onEmailVerification; + eventHandlers[Events.REQUEST_EMAIL_PREVIEW] = onEmailPreview; + eventHandlers[Events.REQUEST_DEVICE_VERIFICATION] = onDeviceVerification; + eventHandlers[Events.REQUEST_QUOTE_START] = onQuoteStart; + eventHandlers[Events.REQUEST_QUOTE_STOP] = onQuoteStop; eventHandlers[Events.REQUEST_CONFIRM_TRANSACTION] = onConfirmTransaction; /** -------------- EVENT HANDLERS ---------------- */ @@ -147,17 +157,6 @@ export function createEventsService(iframeUrl: string, authService: AuthService, } } - async function onRetryLogin(event: StringEvent, { frame }: StringPay) { - if (!frame) throw new Error("Iframe not ready"); - - try { - const { user } = await authService.retryLogin(); - sendEvent(frame, Events.RECEIVE_RETRY_LOGIN, { user }); - } catch (error) { - sendEvent(frame, Events.RECEIVE_RETRY_LOGIN, {}, error); - } - } - async function onUpdateUser(event: StringEvent, { frame }: StringPay) { if (!frame) throw new Error("Iframe not ready"); @@ -178,11 +177,13 @@ export function createEventsService(iframeUrl: string, authService: AuthService, const data = <{ userId: string; email: string }>event.data; await apiClient.requestEmailVerification(data.userId, data.email); - const check = setInterval(async () => { + + clearInterval(emailCheckInterval); + emailCheckInterval = setInterval(async () => { const { status } = await apiClient.getUserStatus(data.userId); if (status == "email_verified") { sendEvent(frame, Events.RECEIVE_EMAIL_VERIFICATION, { status }); - clearInterval(check); + clearInterval(emailCheckInterval); } }, 5000); } catch (error: any) { @@ -190,6 +191,65 @@ export function createEventsService(iframeUrl: string, authService: AuthService, } } + async function onDeviceVerification(event: StringEvent, { frame }: StringPay) { + if (!frame) throw new Error("Iframe not ready"); + + try { + const data = <{ walletAddress: string }>event.data; + + let nonce: string; + let signature: string; + + const previous = await authService.getPreviousSignature(); + nonce = previous.nonce; + signature = previous.signature; + + if (!previous.signature) { + nonce = (await apiClient.requestLogin(data.walletAddress)).nonce; + signature = await authService.requestSignature(data.walletAddress, nonce); + } + + await apiClient.requestDeviceVerification(nonce, signature, previous.visitor); + + clearInterval(deviceCheckInterval); + deviceCheckInterval = setInterval(async () => { + const { status } = await apiClient.getDeviceStatus(nonce, signature, previous.visitor); + if (status == "verified") { + sendEvent(frame, Events.RECEIVE_DEVICE_VERIFICATION, { status }); + clearInterval(deviceCheckInterval); + } + }, 5000); + } catch (error: any) { + sendEvent(frame, Events.RECEIVE_DEVICE_VERIFICATION, {}, error); + } + } + + async function onEmailPreview(event: StringEvent, { frame }: StringPay) { + if (!frame) throw new Error("Iframe not ready"); + + try { + const data = <{ walletAddress: string }>event.data; + + let nonce: string; + let signature: string; + + const previous = await authService.getPreviousSignature(); + nonce = previous.nonce; + signature = previous.signature; + + if (!previous.signature) { + nonce = (await apiClient.requestLogin(data.walletAddress)).nonce; + signature = await authService.requestSignature(data.walletAddress, nonce); + } + + const { email } = await apiClient.getUserEmailPreview(nonce, signature); + + sendEvent(frame, Events.RECEIVE_EMAIL_PREVIEW, { email }); + } catch (error: any) { + sendEvent(frame, Events.RECEIVE_EMAIL_PREVIEW, {}, error); + } + } + async function onQuoteStart(event: StringEvent, { frame, payload }: StringPay) { if (!frame) throw new Error("Iframe not ready");