diff --git a/.env.example b/.env.example index e0fdd24..2ea51e1 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ VITE_PAYMENTS_API_URL= VITE_DRIVE_API_URL= VITE_MAGIC_IV= VITE_MAGIC_SALT= -VITE_CRYPTO_SECRET= \ No newline at end of file +VITE_CRYPTO_SECRET= +VITE_DRIVE_APP_URL= \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 439b72e..194e276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: VITE_MAGIC_IV: ${{ secrets.MAGIC_IV }} VITE_MAGIC_SALT: ${{ secrets.MAGIC_SALT }} VITE_CRYPTO_SECRET: ${{ secrets.CRYPTO_SECRET }} + VITE_DRIVE_APP_URL: https://drive.internxt.com steps: - uses: actions/checkout@v4 @@ -25,7 +26,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: 'yarn' + cache: "yarn" - run: yarn install --frozen-lockfile - run: yarn build diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index f171be8..683e19c 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -2,7 +2,7 @@ name: SonarCloud analysis on: push: - branches: ['master'] + branches: ["master"] pull_request: types: [opened, synchronize, reopened] workflow_dispatch: diff --git a/package.json b/package.json index a5e2a81..f3d86b1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@tiptap/extension-underline": "^3.20.0", "@tiptap/react": "^3.20.0", "@tiptap/starter-kit": "^3.20.0", + "axios": "^1.13.6", "dayjs": "^1.11.19", "i18next": "^25.8.13", "react": "^19.2.0", diff --git a/src/components/mail/composeMessageDialog/components/editorBar/index.tsx b/src/components/mail/composeMessageDialog/components/editorBar/index.tsx index 795588d..bd3ba9b 100644 --- a/src/components/mail/composeMessageDialog/components/editorBar/index.tsx +++ b/src/components/mail/composeMessageDialog/components/editorBar/index.tsx @@ -100,7 +100,7 @@ export const EditorBar = ({ editor, disabled }: ActionBarProps) => { if (!editor) return; const previousUrl = editor.getAttributes('link').href; - const url = window.prompt('URL', previousUrl); + const url = globalThis.prompt('URL', previousUrl); if (url === null) return; @@ -116,7 +116,7 @@ export const EditorBar = ({ editor, disabled }: ActionBarProps) => { const addImage = useCallback(() => { if (!editor) return; - const url = window.prompt('Image URL'); + const url = globalThis.prompt('Image URL'); if (url) { editor.chain().focus().setImage({ src: url }).run(); diff --git a/src/features/welcome/index.tsx b/src/features/welcome/index.tsx index 90d42dd..b828b60 100644 --- a/src/features/welcome/index.tsx +++ b/src/features/welcome/index.tsx @@ -2,9 +2,22 @@ import { Button } from '@internxt/ui'; import smallLogo from '../../assets/logos/small-logo.svg'; import MailAppImage from '../../assets/images/welcome/welcome-page.webp'; import { useTranslationContext } from '@/i18n'; +import { useAuth } from '@/hooks/useAuth'; +import { useNavigation } from '@/hooks/useNavigation'; const WelcomePage = () => { const { translate } = useTranslationContext(); + const { goTo } = useNavigation(); + + const onSuccess = () => { + console.log('onLogin'); + goTo('/inbox'); + }; + + const { handleWebLogin, handleWebSignup } = useAuth({ + onSuccess, + translate, + }); return (
@@ -15,8 +28,12 @@ const WelcomePage = () => {

{translate('meet')}

- - + +
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..6bbeab2 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,103 @@ +import { useCallback, useState } from 'react'; +import { LocalStorageService } from '@/services/local-storage'; +import { PaymentsService } from '@/services/sdk/payments.service'; +import type { LoginCredentials } from '@/types/oauth'; +import { OauthService } from '@/services/oauth/oauth.service'; + +interface UseWebAuthProps { + onSuccess?: (token: string) => void; + translate: (key: string) => string; +} + +export function useAuth({ onSuccess, translate }: UseWebAuthProps) { + const [webAuthError, setWebAuthError] = useState(''); + + const saveUserSession = useCallback( + async (credentials: LoginCredentials) => { + LocalStorageService.instance.saveCredentials(credentials.user, credentials.mnemonic, credentials.newToken); + + try { + const [userTier, userSubscription] = await Promise.all([ + PaymentsService.instance.getUserTier(), + PaymentsService.instance.getUserSubscription(), + ]); + + LocalStorageService.instance.setTier(userTier); + LocalStorageService.instance.setSubscription(userSubscription); + } catch (err) { + console.error('Error getting user subscription and tier:', err); + } + + onSuccess?.(credentials.newToken); + }, + [LocalStorageService, onSuccess], + ); + + /** + * Handles web-based login using popup window + */ + const handleWebLogin = async () => { + setWebAuthError(''); + + try { + const credentials = await OauthService.instance.loginWithWeb(); + + if (!credentials?.newToken || !credentials?.user) { + throw new Error(translate('meet.auth.modal.error.invalidCredentials')); + } + + await saveUserSession(credentials); + } catch (err: unknown) { + errorHandler(err); + } + }; + + /** + * Handles web-based signup using popup window + */ + const handleWebSignup = async () => { + setWebAuthError(''); + + try { + const credentials = await OauthService.instance.signupWithWeb(); + + if (!credentials?.newToken || !credentials?.user) { + throw new Error(translate('meet.auth.modal.error.invalidCredentials')); + } + + await saveUserSession(credentials); + } catch (err: unknown) { + errorHandler(err); + } + }; + + const errorHandler = useCallback( + (err: unknown) => { + if (err instanceof Error) { + if (err.message.includes('popup blocker')) { + setWebAuthError(translate('meet.auth.modal.error.popupBlocked')); + } else if (err.message.includes('cancelled')) { + setWebAuthError(translate('meet.auth.modal.error.authCancelled')); + } else if (err.message.includes('timeout')) { + setWebAuthError(translate('meet.auth.modal.error.authTimeout')); + } else { + setWebAuthError(err.message); + } + } else { + setWebAuthError(translate('meet.auth.modal.error.genericError')); + } + }, + [setWebAuthError], + ); + + const resetState = useCallback(() => { + setWebAuthError(''); + }, []); + + return { + webAuthError, + handleWebLogin, + handleWebSignup, + resetState, + }; +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index c7ee75c..0aca659 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,6 +1,6 @@ import { type RouteObject, Navigate } from 'react-router-dom'; import { lazy } from 'react'; -import RootLayout from '@/layouts/RootLayout'; +import RootLayout from '@/routes/layouts/RootLayout'; const WelcomePage = lazy(() => import('@/features/welcome')); const MailView = lazy(() => import('@/features/mail/MailView')); diff --git a/src/layouts/RootLayout.tsx b/src/routes/layouts/RootLayout.tsx similarity index 100% rename from src/layouts/RootLayout.tsx rename to src/routes/layouts/RootLayout.tsx diff --git a/src/routes/paths.ts b/src/routes/paths.ts index 40178ea..ddf61d7 100644 --- a/src/routes/paths.ts +++ b/src/routes/paths.ts @@ -1,4 +1,5 @@ export const PATHS = { + welcome: '/welcome', inbox: '/inbox', trash: '/trash', } as const; diff --git a/src/services/config/config.service.test.ts b/src/services/config/config.service.test.ts index 79ee486..eb4a140 100644 --- a/src/services/config/config.service.test.ts +++ b/src/services/config/config.service.test.ts @@ -10,6 +10,7 @@ describe('Config Service', () => { vi.stubEnv('VITE_CRYPTO_SECRET', 'test-secret'); vi.stubEnv('VITE_MAGIC_IV', 'test-iv'); vi.stubEnv('VITE_MAGIC_SALT', 'test-salt'); + vi.stubEnv('VITE_DRIVE_APP_URL', 'https://drive.internxt.com'); vi.stubEnv('PROD', false); }); diff --git a/src/services/config/index.ts b/src/services/config/index.ts index 898b415..794d177 100644 --- a/src/services/config/index.ts +++ b/src/services/config/index.ts @@ -7,6 +7,7 @@ interface ConfigKeys { CRYPTO_SECRET: string; MAGIC_IV: string; MAGIC_SALT: string; + DRIVE_APP_URL: string; } const configKeys: Record = { @@ -16,6 +17,7 @@ const configKeys: Record = { CRYPTO_SECRET: 'VITE_CRYPTO_SECRET', MAGIC_IV: 'VITE_MAGIC_IV', MAGIC_SALT: 'VITE_MAGIC_SALT', + DRIVE_APP_URL: 'VITE_DRIVE_APP_URL', }; export class ConfigService { diff --git a/src/services/local-storage/index.ts b/src/services/local-storage/index.ts index ae13317..6fb2e02 100644 --- a/src/services/local-storage/index.ts +++ b/src/services/local-storage/index.ts @@ -1,9 +1,13 @@ +import type { Tier } from '@internxt/sdk/dist/drive/payments/types/tiers'; import type { UserSubscription } from '@internxt/sdk/dist/drive/payments/types/types'; import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; const LocalStorageKeys = { xUser: 'xUser', + xMnemonic: 'xMnemonic', xNewToken: 'xNewToken', + xSubscription: 'xSubscription', + xTier: 'xTier', }; export class LocalStorageService { @@ -43,15 +47,15 @@ export class LocalStorageService { } setMnemonic(mnemonic: string) { - localStorage.setItem('xMnemonic', mnemonic); + localStorage.setItem(LocalStorageKeys.xMnemonic, mnemonic); } getMnemonic(): string | null { - return localStorage.getItem('xMnemonic'); + return localStorage.getItem(LocalStorageKeys.xMnemonic); } - setSubscription(subscription: UserSubscription) { - localStorage.setItem('xSubscription', JSON.stringify(subscription)); + setSubscription(subscription: UserSubscription): void { + localStorage.setItem(LocalStorageKeys.xSubscription, JSON.stringify(subscription)); } getSubscription(): UserSubscription | null { @@ -59,6 +63,15 @@ export class LocalStorageService { return subscription ? JSON.parse(subscription) : null; } + setTier(subscription: Tier): void { + localStorage.setItem(LocalStorageKeys.xTier, JSON.stringify(subscription)); + } + + getTier(): Tier | null { + const subscription = localStorage.getItem(LocalStorageKeys.xTier); + return subscription ? JSON.parse(subscription) : null; + } + saveCredentials(user: UserSettings, mnemonic: string, token: string) { this.setUser(user); this.setMnemonic(mnemonic); @@ -66,9 +79,6 @@ export class LocalStorageService { } clearCredentials() { - localStorage.removeItem('xUser'); - localStorage.removeItem('xNewToken'); - localStorage.removeItem('xSubscription'); - localStorage.removeItem('xMnemonic'); + Object.values(LocalStorageKeys).forEach((key) => this.remove(key)); } } diff --git a/src/services/oauth/errors/oauth.errors.ts b/src/services/oauth/errors/oauth.errors.ts new file mode 100644 index 0000000..3747b9a --- /dev/null +++ b/src/services/oauth/errors/oauth.errors.ts @@ -0,0 +1,43 @@ +export class MissingAuthParamsToken extends Error { + constructor() { + super('Missing auth params token'); + + Object.setPrototypeOf(this, MissingAuthParamsToken.prototype); + } +} + +export class AuthCancelledByUserError extends Error { + constructor() { + super('Authentication cancelled by user'); + + Object.setPrototypeOf(this, AuthCancelledByUserError.prototype); + } +} + +export class AuthTimeoutError extends Error { + constructor() { + super('Authentication timed out'); + + Object.setPrototypeOf(this, AuthTimeoutError.prototype); + } +} + +export class OpenAuthPopupError extends Error { + constructor() { + super( + 'Failed to open authentication popup. Please check your popup blocker settings.', + ); + + Object.setPrototypeOf(this, OpenAuthPopupError.prototype); + } +} + +export class WebAuthProcessingError extends Error { + constructor(cause?: Error) { + super( + `Web authentication processing failed: ${cause instanceof Error ? cause.message : 'Unknown error'}`, + ); + + Object.setPrototypeOf(this, WebAuthProcessingError.prototype); + } +} diff --git a/src/services/oauth/oauth.service.test.ts b/src/services/oauth/oauth.service.test.ts new file mode 100644 index 0000000..7204aa4 --- /dev/null +++ b/src/services/oauth/oauth.service.test.ts @@ -0,0 +1,406 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OauthService } from './oauth.service'; +import { UserService } from '../user/user.service'; +import { LocalStorageService } from '../local-storage'; +import { WEB_AUTH_CONFIG, WEB_AUTH_MESSAGE_TYPES, type WebAuthMessage, type WebAuthParams } from '@/types/oauth'; +import { AuthCancelledByUserError, MissingAuthParamsToken, WebAuthProcessingError } from './errors/oauth.errors'; + +vi.mock('../config', () => ({ + ConfigService: { + instance: { + getVariable: (key: string) => { + const config: Record = { + DRIVE_APP_URL: 'https://drive.internxt.com', + }; + return config[key] || ''; + }, + }, + }, +})); + +describe('OAuth Service', () => { + let service: OauthService; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(UserService.instance, 'getUser').mockResolvedValue({ + userId: '123', + email: 'test@example.com', + name: 'Test', + lastname: 'User', + } as any); + + vi.spyOn(LocalStorageService.instance, 'setToken').mockImplementation(() => {}); + + service = OauthService.instance; + }); + + describe('Processing authentication credentials', () => { + it('when credentials are received, then the mnemonic is decoded from base64', async () => { + const mnemonic = 'test mnemonic phrase'; + const encodedMnemonic = Buffer.from(mnemonic).toString('base64'); + const encodedNewToken = Buffer.from('test-new-token').toString('base64'); + + const params: WebAuthParams = { + mnemonic: encodedMnemonic, + newToken: encodedNewToken, + }; + + const result = await (service as any).processWebAuthParams(params); + + expect(result.mnemonic).toBe(mnemonic); + expect(result.user.mnemonic).toBe(mnemonic); + }); + + it('when credentials are processed, then the token is stored in local storage', async () => { + const newToken = 'test-new-token-value'; + const encodedNewToken = Buffer.from(newToken).toString('base64'); + const encodedMnemonic = Buffer.from('test mnemonic').toString('base64'); + + const setTokenSpy = vi.spyOn(LocalStorageService.instance, 'setToken'); + + const params: WebAuthParams = { + mnemonic: encodedMnemonic, + newToken: encodedNewToken, + }; + + await (service as any).processWebAuthParams(params); + + expect(setTokenSpy).toHaveBeenCalledWith(newToken); + }); + + it('when credentials are stored, then user data is fetched from the API', async () => { + const encodedNewToken = Buffer.from('test-new-token').toString('base64'); + const encodedMnemonic = Buffer.from('test mnemonic').toString('base64'); + + const getUserSpy = vi.spyOn(UserService.instance, 'getUser'); + + const params: WebAuthParams = { + mnemonic: encodedMnemonic, + newToken: encodedNewToken, + }; + + await (service as any).processWebAuthParams(params); + + expect(getUserSpy).toHaveBeenCalledTimes(1); + }); + + it('when credentials are fully processed, then complete authentication data is returned', async () => { + const mnemonic = 'test mnemonic phrase'; + const newToken = 'test-new-token-value'; + const encodedMnemonic = Buffer.from(mnemonic).toString('base64'); + const encodedNewToken = Buffer.from(newToken).toString('base64'); + + const params: WebAuthParams = { + mnemonic: encodedMnemonic, + newToken: encodedNewToken, + }; + + const result = await (service as any).processWebAuthParams(params); + + expect(result).toHaveProperty('user'); + expect(result).toHaveProperty('newToken', newToken); + expect(result).toHaveProperty('mnemonic', mnemonic); + expect(result.user).toHaveProperty('mnemonic', mnemonic); + }); + + it('when user data cannot be retrieved, then an error indicating so is thrown', async () => { + vi.spyOn(UserService.instance, 'getUser').mockRejectedValue(new Error('API Error')); + + const params: WebAuthParams = { + mnemonic: Buffer.from('test').toString('base64'), + newToken: Buffer.from('new-token').toString('base64'), + }; + + await expect((service as any).processWebAuthParams(params)).rejects.toThrow(WebAuthProcessingError); + }); + }); + + describe('Validating authentication parameters', () => { + it('when all required parameters are provided, then validation passes', () => { + const params: WebAuthParams = { + mnemonic: 'test-mnemonic', + newToken: 'test-token', + }; + + const isValid = (service as any).validateAuthParams(params); + + expect(isValid).toBe(true); + }); + + it('when the mnemonic is missing, then validation fails', () => { + const params = { + newToken: 'test-token', + }; + + const isValid = (service as any).validateAuthParams(params); + + expect(isValid).toBe(false); + }); + + it('when the token is missing, then validation fails', () => { + const params = { + mnemonic: 'test-mnemonic', + }; + + const isValid = (service as any).validateAuthParams(params); + + expect(isValid).toBe(false); + }); + + it('when no parameters are provided, then validation fails', () => { + const params = {}; + + const isValid = (service as any).validateAuthParams(params); + + expect(isValid).toBe(false); + }); + }); + + describe('Origin security validation', () => { + it('when the origin is from internxt.com domain, then it is accepted', () => { + const isValid = (service as any).isValidOrigin('https://drive.internxt.com'); + + expect(isValid).toBe(true); + }); + + it('when the origin is from localhost, then it is accepted', () => { + const isValid = (service as any).isValidOrigin('http://localhost:3000'); + + expect(isValid).toBe(true); + }); + + it('when the origin is from an unknown domain, then it is rejected', () => { + const isValid = (service as any).isValidOrigin('https://malicious-site.com'); + + expect(isValid).toBe(false); + }); + + it('when the origin is empty, then it is rejected', () => { + const isValid = (service as any).isValidOrigin(''); + + expect(isValid).toBe(false); + }); + }); + + describe('Handling authentication success messages', () => { + it('when a success message with valid credentials is received, then authentication completes successfully', () => { + const mockResolve = vi.fn(); + const mockReject = vi.fn(); + const mockTimeout = setTimeout(() => {}, 1000) as any; + + const payload: WebAuthParams = { + mnemonic: 'test-mnemonic', + newToken: 'test-token', + }; + + const message: WebAuthMessage = { + type: WEB_AUTH_MESSAGE_TYPES.SUCCESS, + payload, + }; + + (service as any).handleAuthSuccess(message, mockResolve, mockReject, mockTimeout); + + expect(mockResolve).toHaveBeenCalledWith(payload); + expect(mockReject).not.toHaveBeenCalled(); + }); + + it('when a success message has incomplete credentials, then authentication fails', () => { + const mockResolve = vi.fn(); + const mockReject = vi.fn(); + const mockTimeout = setTimeout(() => {}, 1000) as any; + + const message: WebAuthMessage = { + type: WEB_AUTH_MESSAGE_TYPES.SUCCESS, + payload: { mnemonic: 'test' } as any, + }; + + (service as any).handleAuthSuccess(message, mockResolve, mockReject, mockTimeout); + + expect(mockReject).toHaveBeenCalledWith(expect.any(MissingAuthParamsToken)); + expect(mockResolve).not.toHaveBeenCalled(); + }); + }); + + describe('Handling authentication error messages', () => { + it('when an error message with a description is received, then authentication fails with that error', () => { + const mockReject = vi.fn(); + const mockTimeout = setTimeout(() => {}, 1000) as any; + + const message: WebAuthMessage = { + type: WEB_AUTH_MESSAGE_TYPES.ERROR, + error: 'Authentication failed', + }; + + (service as any).handleAuthError(message, mockReject, mockTimeout); + + expect(mockReject).toHaveBeenCalledWith(new Error('Authentication failed')); + }); + + it('when an error message without a description is received, then authentication fails with a default error', () => { + const mockReject = vi.fn(); + const mockTimeout = setTimeout(() => {}, 1000) as any; + + const message: WebAuthMessage = { + type: WEB_AUTH_MESSAGE_TYPES.ERROR, + }; + + (service as any).handleAuthError(message, mockReject, mockTimeout); + + expect(mockReject).toHaveBeenCalledWith(new Error('Authentication failed')); + }); + }); + + describe('Decoding base64 parameters', () => { + it('when a base64-encoded string is received, then it is decoded to plain text', () => { + const originalText = 'test text with spaces'; + const encoded = Buffer.from(originalText).toString('base64'); + + const decoded = (service as any).decodeBase64Param(encoded); + + expect(decoded).toBe(originalText); + }); + + it('when the encoded string contains special characters, then they are preserved after decoding', () => { + const originalText = 'test!@#$%^&*()_+-=[]{}|;:",.<>?'; + const encoded = Buffer.from(originalText).toString('base64'); + + const decoded = (service as any).decodeBase64Param(encoded); + + expect(decoded).toBe(originalText); + }); + + it('when the encoded string contains unicode characters, then they are preserved after decoding', () => { + const originalText = 'test émojis 😀🎉 and àccénts'; + const encoded = Buffer.from(originalText).toString('base64'); + + const decoded = (service as any).decodeBase64Param(encoded); + + expect(decoded).toBe(originalText); + }); + }); + + describe('Popup window positioning', () => { + it('when the popup is created, then it is centered on the screen', () => { + Object.defineProperty(window, 'screen', { + value: { + width: 1920, + height: 1080, + }, + writable: true, + }); + + const { left, top } = (service as any).calculatePopupPosition(); + + const expectedLeft = 1920 / 2 - WEB_AUTH_CONFIG.popupWidth / 2; + const expectedTop = 1080 / 2 - WEB_AUTH_CONFIG.popupHeight / 2; + + expect(left).toBe(expectedLeft); + expect(top).toBe(expectedTop); + }); + + it('when the screen size changes, then the popup position is recalculated correctly', () => { + Object.defineProperty(window, 'screen', { + value: { + width: 1366, + height: 768, + }, + writable: true, + }); + + const { left, top } = (service as any).calculatePopupPosition(); + + const expectedLeft = 1366 / 2 - WEB_AUTH_CONFIG.popupWidth / 2; + const expectedTop = 768 / 2 - WEB_AUTH_CONFIG.popupHeight / 2; + + expect(left).toBe(expectedLeft); + expect(top).toBe(expectedTop); + }); + }); + + describe('Popup window configuration', () => { + it('when the popup window is configured, then it includes all required security and layout features', () => { + const left = 100; + const top = 200; + + const features = (service as any).buildPopupFeatures(left, top); + + expect(features).toContain(`width=${WEB_AUTH_CONFIG.popupWidth}`); + expect(features).toContain(`height=${WEB_AUTH_CONFIG.popupHeight}`); + expect(features).toContain(`left=${left}`); + expect(features).toContain(`top=${top}`); + expect(features).toContain('toolbar=no'); + expect(features).toContain('menubar=no'); + expect(features).toContain('location=no'); + expect(features).toContain('status=no'); + }); + }); + + describe('Popup closed detection', () => { + it('when the popup is closed by the user, then authentication is cancelled', () => { + vi.useFakeTimers(); + + const mockReject = vi.fn(); + const mockTimeout = setTimeout(() => {}, 1000) as any; + const mockPopup = { closed: true } as Window; + + const interval = (service as any).setupPopupClosedChecker(mockPopup, mockReject, mockTimeout); + + // Trigger the interval check + vi.advanceTimersByTime(WEB_AUTH_CONFIG.popupCheckIntervalMs); + + expect(mockReject).toHaveBeenCalledWith(expect.any(AuthCancelledByUserError)); + + clearInterval(interval); + vi.useRealTimers(); + }); + }); + + describe('Building login credentials', () => { + it('when user data is available, then login credentials are built correctly', () => { + const user = { + userId: '123', + email: 'test@example.com', + name: 'Test', + lastname: 'User', + }; + const mnemonic = 'test mnemonic'; + const newToken = 'test-token'; + + const credentials = (service as any).buildLoginCredentials(user, mnemonic, newToken); + + expect(credentials).toHaveProperty('user'); + expect(credentials).toHaveProperty('newToken', newToken); + expect(credentials).toHaveProperty('mnemonic', mnemonic); + expect(credentials.user).toHaveProperty('mnemonic', mnemonic); + expect(credentials.user).toHaveProperty('email', user.email); + }); + }); + + describe('Cleanup', () => { + it('when cleanup is called, then all resources are cleaned up', () => { + const mockPopup = { + closed: false, + close: vi.fn(), + }; + const mockListener = vi.fn(); + + (service as any).authPopup = mockPopup; + (service as any).messageListener = mockListener; + (service as any).popupCheckInterval = setInterval(() => {}, 1000); + + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + + (service as any).cleanup(); + + expect(mockPopup.close).toHaveBeenCalled(); + expect((service as any).authPopup).toBeNull(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('message', mockListener); + expect((service as any).messageListener).toBeNull(); + expect((service as any).popupCheckInterval).toBeNull(); + }); + }); +}); diff --git a/src/services/oauth/oauth.service.ts b/src/services/oauth/oauth.service.ts new file mode 100644 index 0000000..8026549 --- /dev/null +++ b/src/services/oauth/oauth.service.ts @@ -0,0 +1,274 @@ +import { + WEB_AUTH_CONFIG, + WEB_AUTH_MESSAGE_TYPES, + WEB_AUTH_VALID_ORIGINS, + type LoginCredentials, + type WebAuthMessage, + type WebAuthParams, +} from '@/types/oauth'; +import { UserService } from '../user/user.service'; +import { ConfigService } from '../config'; +import { + AuthCancelledByUserError, + AuthTimeoutError, + MissingAuthParamsToken, + OpenAuthPopupError, + WebAuthProcessingError, +} from './errors/oauth.errors'; +import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { LocalStorageService } from '../local-storage'; + +export class OauthService { + public static readonly instance: OauthService = new OauthService(); + private readonly DRIVE_APP_URL = ConfigService.instance.getVariable('DRIVE_APP_URL'); + + private authPopup: Window | null = null; + private messageListener: ((event: MessageEvent) => void) | null = null; + private popupCheckInterval: NodeJS.Timeout | null = null; + + /** + * Get the web auth URLs for login and signup + */ + public get urls() { + return { + login: `${this.DRIVE_APP_URL}${WEB_AUTH_CONFIG.loginPath}?${WEB_AUTH_CONFIG.authOriginParam}`, + signup: `${this.DRIVE_APP_URL}${WEB_AUTH_CONFIG.signupPath}?${WEB_AUTH_CONFIG.authOriginParam}`, + }; + } + + /** + * Calculate popup position to center it on screen + */ + private calculatePopupPosition() { + const left = window.screen.width / 2 - WEB_AUTH_CONFIG.popupWidth / 2; + const top = window.screen.height / 2 - WEB_AUTH_CONFIG.popupHeight / 2; + + return { left, top }; + } + + /** + * Build popup window features string + */ + private buildPopupFeatures(left: number, top: number): string { + return [ + `width=${WEB_AUTH_CONFIG.popupWidth}`, + `height=${WEB_AUTH_CONFIG.popupHeight}`, + `left=${left}`, + `top=${top}`, + 'toolbar=no', + 'menubar=no', + 'location=no', + 'status=no', + ].join(','); + } + + /** + * Opens a popup window for web authentication + * @param url The URL to open in the popup + * @returns Window reference or null if popup was blocked + */ + private openAuthPopup(url: string): Window | null { + const { left, top } = this.calculatePopupPosition(); + const features = this.buildPopupFeatures(left, top); + + const popup = window.open(url, WEB_AUTH_CONFIG.popupName, features); + + return popup; + } + + /** + * Validate origin of postMessage event + */ + private isValidOrigin(origin: string): boolean { + return WEB_AUTH_VALID_ORIGINS.some((valid) => origin.includes(valid)); + } + + /** + * Validate authentication parameters + */ + private validateAuthParams(params: Partial): params is WebAuthParams { + return !!(params.mnemonic && params.newToken); + } + + /** + * Handle auth success message + */ + private handleAuthSuccess( + data: WebAuthMessage, + resolve: (value: WebAuthParams) => void, + reject: (reason: Error) => void, + timeout: NodeJS.Timeout, + ) { + clearTimeout(timeout); + this.cleanup(); + + const { payload } = data; + + if (!payload || !this.validateAuthParams(payload)) { + reject(new MissingAuthParamsToken()); + return; + } + + resolve(payload); + } + + /** + * Handle auth error message + */ + private handleAuthError(data: WebAuthMessage, reject: (reason: Error) => void, timeout: NodeJS.Timeout) { + clearTimeout(timeout); + this.cleanup(); + reject(new Error(data.error || 'Authentication failed')); + } + + /** + * Setup popup closed checker interval + */ + private setupPopupClosedChecker( + popup: Window, + reject: (reason: Error) => void, + timeout: NodeJS.Timeout, + ): NodeJS.Timeout { + return setInterval(() => { + if (popup.closed) { + if (this.popupCheckInterval) clearInterval(this.popupCheckInterval); + clearTimeout(timeout); + this.cleanup(); + reject(new AuthCancelledByUserError()); + } + }, WEB_AUTH_CONFIG.popupCheckIntervalMs); + } + + /** + * Waits for authentication response from the popup window + * @param popup The popup window reference + * @returns Promise that resolves with the authentication parameters + */ + private waitForAuthResponse(popup: Window): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.cleanup(); + reject(new AuthTimeoutError()); + }, WEB_AUTH_CONFIG.authTimeoutMs); + + this.messageListener = (event: MessageEvent) => { + if (!this.isValidOrigin(event.origin)) { + console.warn('Invalid origin for auth message:', event.origin); + return; + } + + const { data } = event; + + if (data?.type === WEB_AUTH_MESSAGE_TYPES.SUCCESS) { + this.handleAuthSuccess(data, resolve, reject, timeout); + } + + if (data?.type === WEB_AUTH_MESSAGE_TYPES.ERROR) { + this.handleAuthError(data, reject, timeout); + } + }; + + window.addEventListener('message', this.messageListener); + + this.popupCheckInterval = this.setupPopupClosedChecker(popup, reject, timeout); + }); + } + + /** + * Cleanup popup and event listeners + */ + private cleanup() { + if (this.authPopup && !this.authPopup.closed) { + this.authPopup.close(); + } + this.authPopup = null; + + if (this.messageListener) { + window.removeEventListener('message', this.messageListener); + this.messageListener = null; + } + + if (this.popupCheckInterval) { + clearInterval(this.popupCheckInterval); + this.popupCheckInterval = null; + } + } + + /** + * Decode base64 parameter + */ + private decodeBase64Param(param: string): string { + return Buffer.from(param, 'base64').toString('utf-8'); + } + + /** + * Build login credentials response + */ + private buildLoginCredentials(user: UserSettings, mnemonic: string, newToken: string): LoginCredentials { + return { + user: { + ...user, + mnemonic, + } as unknown as LoginCredentials['user'], + newToken, + mnemonic, + }; + } + + /** + * Process web authentication parameters and return login credentials + * @param params The authentication parameters from the web + * @returns The processed login credentials + */ + private async processWebAuthParams(params: WebAuthParams): Promise { + try { + const mnemonic = this.decodeBase64Param(params.mnemonic); + const newToken = this.decodeBase64Param(params.newToken); + + LocalStorageService.instance.setToken(newToken); + + const user = await UserService.instance.getUser(); + + return this.buildLoginCredentials(user as unknown as LoginCredentials['user'], mnemonic, newToken); + } catch (error) { + console.error('Error while processing web auth params', error); + throw new WebAuthProcessingError(error as Error); + } + } + + /** + * Execute web authentication flow + */ + private async executeWebAuth(url: string): Promise { + try { + this.authPopup = this.openAuthPopup(url); + + if (!this.authPopup) { + throw new OpenAuthPopupError(); + } + + const authParams = await this.waitForAuthResponse(this.authPopup); + + return await this.processWebAuthParams(authParams); + } catch (error) { + this.cleanup(); + throw error; + } + } + + /** + * Initiates web-based login flow + * @returns Promise that resolves with login credentials + */ + public async loginWithWeb(): Promise { + return this.executeWebAuth(this.urls.login); + } + + /** + * Initiates web-based signup flow + * @returns Promise that resolves with login credentials + */ + public async signupWithWeb(): Promise { + return this.executeWebAuth(this.urls.signup); + } +} diff --git a/src/services/user/user.service.test.ts b/src/services/user/user.service.test.ts new file mode 100644 index 0000000..d844527 --- /dev/null +++ b/src/services/user/user.service.test.ts @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, test, vi } from 'vitest'; +import { SdkManager } from '../sdk'; +import { UserService } from './user.service'; + +describe('User Service', () => { + describe('Get the current logged in user', () => { + test('When a user is logged in, then it should be returned', async () => { + const mockedUser = { + user: { name: 'John Doe' }, + oldToken: 'oldToken', + newToken: 'newToken', + }; + + const mockedUserClient = { + refreshUser: vi.fn().mockResolvedValue(mockedUser), + }; + vi.spyOn(SdkManager.instance, 'getUsers').mockReturnValue( + mockedUserClient as any, + ); + + const result = await UserService.instance.getUser(); + + expect(result).toEqual(mockedUser.user); + }); + }); + + describe('Refresh user data and tokens', () => { + test('When refreshing user data, then it should be returned', async () => { + const mockedUser = { + user: { name: 'John Doe' }, + oldToken: 'oldToken', + newToken: 'newToken', + }; + + const mockedUserClient = { + refreshUser: vi.fn().mockResolvedValue(mockedUser), + }; + vi.spyOn(SdkManager.instance, 'getUsers').mockReturnValue( + mockedUserClient as any, + ); + + const result = await UserService.instance.refreshUserAndTokens(); + + expect(result).toEqual(mockedUser); + }); + }); +}); diff --git a/src/services/user/user.service.ts b/src/services/user/user.service.ts new file mode 100644 index 0000000..5c012b9 --- /dev/null +++ b/src/services/user/user.service.ts @@ -0,0 +1,28 @@ +import { SdkManager } from '../sdk'; + +export class UserService { + public static readonly instance: UserService = new UserService(); + + /** + * Obtains the current logged in user + * + * @returns The current user + */ + public getUser = async () => { + const usersClient = SdkManager.instance.getUsers(); + + const { user } = await usersClient.refreshUser(); + + return user; + }; + + /** + * Refreshes user tokens and data + * @returns The refreshed user data and tokens + */ + public refreshUserAndTokens = async () => { + const usersClient = SdkManager.instance.getUsers(); + const refreshResponse = await usersClient.refreshUser(); + return refreshResponse; + }; +} diff --git a/src/types/oauth/index.ts b/src/types/oauth/index.ts new file mode 100644 index 0000000..7e364c3 --- /dev/null +++ b/src/types/oauth/index.ts @@ -0,0 +1,53 @@ +import { type UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; + +export interface LoginCredentials { + user: UserSettings + newToken: string + mnemonic: string +} + +export interface WebAuthParams { + mnemonic: string + newToken: string +} + +export interface WebAuthMessage { + type: + | typeof WEB_AUTH_MESSAGE_TYPES.SUCCESS + | typeof WEB_AUTH_MESSAGE_TYPES.ERROR + payload?: WebAuthParams + error?: string +} + +export interface WebAuthConfig { + popupWidth: number + popupHeight: number + authTimeoutMs: number + popupCheckIntervalMs: number + popupName: string + authOriginParam: string + loginPath: string + signupPath: string +} + +export const WEB_AUTH_MESSAGE_TYPES = { + SUCCESS: 'INTERNXT_AUTH_SUCCESS', + ERROR: 'INTERNXT_AUTH_ERROR', +} as const; + +export const WEB_AUTH_CONFIG: WebAuthConfig = { + popupWidth: 500, + popupHeight: 700, + authTimeoutMs: 5 * 60 * 1000, + popupCheckIntervalMs: 500, + popupName: 'InternxtAuth', + authOriginParam: 'authOrigin=mail', + loginPath: '/login', + signupPath: '/new', +}; + +export const WEB_AUTH_VALID_ORIGINS = [ + 'internxt.com', + 'localhost', + 'pages.dev', +] as const; diff --git a/vite.config.ts b/vite.config.ts index 8108b6c..48bc6b7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ }), ], server: { - port: 3000, + port: 3001, }, resolve: { alias: { diff --git a/yarn.lock b/yarn.lock index ffd9c0d..1f40972 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2194,6 +2194,15 @@ axios@1.13.5: form-data "^4.0.5" proxy-from-env "^1.1.0" +axios@^1.13.6: + version "1.13.6" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.6.tgz#c3f92da917dc209a15dd29936d20d5089b6b6c98" + integrity sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ== + dependencies: + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^1.1.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"