Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: user authentication flow #9

Open
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

aliakbar-deriv
Copy link
Collaborator

@aliakbar-deriv aliakbar-deriv commented Jan 29, 2025

Summary by Sourcery

Implement user authentication with enhanced security and session management. Securely store user data with encryption and handle authentication through Telegram Web App or OAuth. Manage multiple trading accounts and authorize WebSocket connections using account tokens. Implement comprehensive session validation and error handling.

New Features:

  • Secure user authentication flow using Telegram Web App or OAuth.
  • Support for multiple trading accounts with encrypted storage.
  • Automatic WebSocket authorization with account tokens.

Tests:

  • Added comprehensive tests for ProtectedRoute component, covering authentication states, loading, navigation, and error handling.
  • Improved tests for ErrorBoundary component, covering error states, fallback messages, reset functionality, and CSS classes.
  • Added tests for LoginForm component, covering Telegram login, OAuth callback, loading states, and navigation.

- Move auth URLs to environment variables
- Remove hardcoded URLs from app.config.js
- Add auth configuration documentation in README
- Update .env.example with generic examples
- Improve security by not exposing URLs in git-tracked files
Copy link

sourcery-ai bot commented Jan 29, 2025

Reviewer's Guide by Sourcery

This pull request introduces a comprehensive user authentication flow, integrating Telegram WebApp and external OAuth for user login and session management. It also includes logic for handling trading accounts and default account settings.

Sequence diagram for the new authentication flow

sequenceDiagram
    actor User
    participant UI as Login Page
    participant Auth as Auth Service
    participant Storage as Local Storage
    participant External as OAuth/Telegram

    User->>UI: Access application
    UI->>Auth: initialize()
    Auth->>Storage: Check stored session
    alt Has valid session
        Storage-->>Auth: Return session data
        Auth-->>UI: Return authenticated
        UI->>UI: Redirect to Dashboard
    else No valid session
        alt Has Telegram user
            External-->>Auth: Get Telegram user data
            Auth->>Storage: Store session
            Auth-->>UI: Return authenticated
        else OAuth login
            User->>UI: Click 'Login with existing account'
            UI->>External: Redirect to OAuth URL
            External-->>UI: Return with OAuth data
            UI->>Auth: handleOAuthCallback()
            Auth->>Storage: Store session & accounts
            Auth-->>UI: Return authenticated
        end
    end
Loading

Class diagram for the updated AuthService

classDiagram
    class AuthService {
        -storageKey: string
        -tradingAccountsKey: string
        -defaultAccountKey: string
        +initialize(): Promise<boolean>
        +setSession(session: Object): Promise<boolean>
        +getStoredSession(): Promise<Object>
        +validateSession(session: Object): Promise<boolean>
        +setTradingAccounts(accounts: Array): Promise<boolean>
        +getTradingAccounts(): Promise<Array>
        +setDefaultAccount(account: Object): Promise<boolean>
        +getDefaultAccount(): Promise<Object>
        +clearSession(): Promise<boolean>
        +isAuthenticated(): boolean
    }

    class useAuth {
        -user: Object
        -defaultAccount: Object
        -isAuthenticated: boolean
        -isLoading: boolean
        +initialize(): Promise<boolean>
        +login(telegramUser, oauthData): Promise<boolean>
        +handleOAuthCallback(searchParams): Promise<boolean>
        +logout(): Promise<boolean>
    }

    useAuth ..> AuthService : uses
Loading

File-Level Changes

Change Details Files
Implement user authentication and session management
  • Added methods for setting, getting, and validating user sessions.
  • Implemented logic to store and retrieve trading accounts.
  • Added methods to set and get the default trading account.
  • Implemented logic to clear all session data.
  • Updated the isAuthenticated method to check for both session and trading accounts.
  • Added encryption/decryption for sensitive data in production (commented out).
  • Added a small delay to simulate network latency in development.
src/services/auth.service.js
Integrate authentication flow into the application
  • Added logic to initialize authentication state on app load.
  • Implemented redirects based on authentication status.
  • Added a loading state during initial authentication check.
  • Handled OAuth callback redirect.
  • Updated routes to use the new authentication logic.
src/App.jsx
Update AppBar to reflect authentication state
  • Added logic to display account information and logout button.
  • Added a loading state to the account details.
  • Updated styles to support the new loading state.
  • Added a logout confirmation dialog.
  • Added a small delay before logout to allow dropdown animation to complete.
src/shared/components/AppBar/AppBar.jsx
src/shared/components/AppBar/AppBar.module.css
Implement login page with multiple authentication options
  • Refactored LoginForm to use the new auth hook.
  • Implemented a new LoginPage component with multiple login options.
  • Added styles for the new login page.
  • Added logic to handle Telegram login, existing account login, and signup redirects.
  • Added a loading state during login processing.
  • Handled OAuth callback redirect.
src/features/auth/components/LoginForm/LoginForm.jsx
src/features/auth/components/LoginPage/LoginPage.jsx
src/features/auth/components/LoginPage/LoginPage.module.css
src/features/auth/components/LoginPage/index.js
Update Dashboard to use authentication data
  • Updated Dashboard to use the new auth hook for account data.
  • Added a loading state while fetching account data.
src/features/home/components/Dashboard/Dashboard.jsx
Implement protected routes
  • Added logic to protect routes based on authentication status.
  • Added a loading state while checking authentication.
src/shared/components/ProtectedRoute/ProtectedRoute.jsx
Update HTTP service to include authentication token
  • Updated the HTTP service to include the authentication token in requests.
  • Added logic to handle 401 errors and redirect to login.
src/services/http.service.js
Update README with authentication configuration
  • Added documentation for authentication configuration.
README.md
Add authentication configuration to app config
  • Added external OAuth and signup URLs to the app configuration.
src/config/app.config.js
Update button styles
  • Updated secondary button styles.
src/shared/components/Button/Button.module.css
Implement useAuth hook
  • Created a new useAuth hook to manage authentication state.
  • Added logic to handle login, logout, and session initialization.
  • Added logic to handle OAuth callbacks.
src/hooks/index.js
src/hooks/useAuth.js
Update landing page button
  • Updated the landing page button text.
src/features/home/components/LandingPage/LandingPage.jsx
Update vite config
  • Added host and allowed hosts to vite config.
vite.config.js

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!
  • Generate a plan of action for an issue: Comment @sourcery-ai plan on
    an issue to generate a plan of action for it.

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

vercel bot commented Jan 29, 2025

@aliakbar-deriv is attempting to deploy a commit to the Deriv Team on Vercel.

A member of the Team first needs to authorize it.

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @aliakbar-deriv - I've reviewed your changes and found some issues that need to be addressed.

Blocking issues:

  • Sensitive data like trading accounts and tokens should not be stored as plaintext in localStorage (link)

Overall Comments:

  • Consider implementing encryption for sensitive data in localStorage before deploying to production. Storing auth tokens and account details in plaintext is a security risk.
  • The trading account object stored in localStorage contains more information than necessary. Consider storing only essential identifiers and fetching full account details from the API when needed.
Here's what I looked at during the review
  • 🟡 General issues: 2 issues found
  • 🔴 Security: 1 blocking issue, 1 other issue
  • 🟢 Testing: all looks good
  • 🟢 Complexity: all looks good
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

*/
async setTradingAccounts(accounts) {
try {
// In production, encrypt sensitive data before storing
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Sensitive data like trading accounts and tokens should not be stored as plaintext in localStorage

Consider using a secure encryption method or a more secure storage mechanism for sensitive data. Web Storage is vulnerable to XSS attacks and should not store sensitive information unencrypted.

// Close dropdown immediately to prevent UI glitches
setIsDropdownOpen(false);

// Small delay to allow dropdown animation to complete
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Unnecessary setTimeout could cause UI lag

Consider using CSS transitions instead of setTimeout for animations. This would be more performant and avoid artificial delays in the UI.

Suggested implementation:

      setIsDropdownOpen(false);

You'll need to add CSS styles to handle the animation. Add this to your CSS/SCSS file (likely AppBar.scss or similar):

.dropdown {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.1s ease-out, transform 0.1s ease-out;
}

.dropdown.closed {
  opacity: 0;
  transform: translateY(-10px);
  pointer-events: none;
}

Then update your dropdown JSX element to include these classes:

<div 
  ref={dropdownRef}
  className={`dropdown ${isDropdownOpen ? '' : 'closed'}`}
>
  {/* dropdown content */}
</div>

This will create a smooth fade-out and slide-up animation when closing the dropdown, without needing any artificial delays.

Comment on lines 88 to 94
const account = searchParams.get(`acct${index}`);
const token = searchParams.get(`token${index}`);
const currency = searchParams.get(`cur${index}`);

if (!account || !token || !currency) break;

tradingAccounts.push({ account, token, currency });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 suggestion (security): OAuth callback should validate data format before using values

Add validation for the expected format of account, token, and currency values to prevent potential issues with malformed data.

Suggested change
const account = searchParams.get(`acct${index}`);
const token = searchParams.get(`token${index}`);
const currency = searchParams.get(`cur${index}`);
if (!account || !token || !currency) break;
tradingAccounts.push({ account, token, currency });
const account = searchParams.get(`acct${index}`);
const token = searchParams.get(`token${index}`);
const currency = searchParams.get(`cur${index}`);
if (!account || !token || !currency) break;
// Validate account format (alphanumeric, max 50 chars)
if (!/^[a-zA-Z0-9]{1,50}$/.test(account)) {
console.warn(`Invalid account format at index ${index}`);
index++;
continue;
}
// Validate token format (JWT format: three base64 sections separated by dots)
if (!/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/.test(token)) {
console.warn(`Invalid token format at index ${index}`);
index++;
continue;
}
// Validate currency format (3 uppercase letters, ISO 4217)
if (!/^[A-Z]{3}$/.test(currency)) {
console.warn(`Invalid currency format at index ${index}`);
index++;
continue;
}
tradingAccounts.push({ account, token, currency });

// Process OAuth callback if present
if (location.search) {
const searchParams = new URLSearchParams(location.search);
handleOAuthCallback(searchParams).then(success => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Add cleanup to prevent navigation after component unmount

Use a cleanup function with useEffect to prevent navigation attempts if the component unmounts during the OAuth callback processing.

Suggested implementation:

    // Process OAuth callback if present
    useEffect(() => {
      let mounted = true;

      if (location.search) {
        const searchParams = new URLSearchParams(location.search);
        handleOAuthCallback(searchParams).then(success => {
          if (success && mounted) {
            navigate(ROUTES.DASHBOARD, { replace: true });
          }
        });
      }

      return () => {
        mounted = false;
      };
    }, [location.search, navigate]);

You'll need to ensure that useEffect is imported from 'react' at the top of the file:

import { useEffect } from 'react';

Copy link

github-actions bot commented Jan 30, 2025

🚀 Preview: https://champion-telegram-ldo1iwg4x.binary.sx

@aliakbar-deriv
Copy link
Collaborator Author

@sourcery-ai review

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @aliakbar-deriv - I've reviewed your changes and found some issues that need to be addressed.

Blocking issues:

  • Avoid hardcoding the default account key. (link)
  • Avoid hardcoding the token. (link)
  • Avoid hardcoding the token. (link)

Overall Comments:

  • Consider consolidating the auth state management - currently there are multiple interrelated state variables (isAuthenticated, isLoading, isSwitchingAccount) that could be simplified into a single state enum to make the logic more maintainable.
  • Error handling should be more consistent throughout the codebase. Consider creating a centralized error handling utility that standardizes how errors are caught, logged and propagated across the WebSocket and auth services.
Here's what I looked at during the review
  • 🟡 General issues: 6 issues found
  • 🔴 Security: 3 blocking issues
  • 🟢 Testing: all looks good
  • 🟡 Complexity: 1 issue found
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

this.connect();
}, this.reconnectTimeout);
this.reconnectAttempts++;
const delay = this.reconnectTimeout * Math.pow(2, this.reconnectAttempts - 1);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Consider adding a maximum delay cap for exponential backoff

The exponential backoff could lead to very long delays. Consider adding something like: const maxDelay = 30000; const delay = Math.min(this.reconnectTimeout * Math.pow(2, this.reconnectAttempts - 1), maxDelay);

Suggested implementation:

    this.reconnectAttempts++;
    const maxDelay = 30000; // 30 seconds maximum delay
    const delay = Math.min(this.reconnectTimeout * Math.pow(2, this.reconnectAttempts - 1), maxDelay);

    this.emit('reconnecting', {

You might want to consider:

  1. Making maxDelay configurable by adding it as a class property that can be set in the constructor
  2. Adding the maxDelay value to the reconnecting event payload for logging/debugging purposes

*/
async setSession(userData) {
async validateSession(session) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider refactoring complex validation logic into smaller functions

The validation logic is quite nested. Consider breaking it into separate functions like validateSessionStructure(), validateTradingAccounts(), etc.

Suggested implementation:

  /**
   * Validates basic session structure
   * @private
   */
  _validateSessionStructure(session) {
    return session && typeof session === 'object';
  }

  /**
   * Validates session data
   * @private
   */
  async validateSession(session) {
    try {
      // Clear any corrupted data
      if (!session) {
        await this.clearSession();
        return false;
      }

      // Validate basic session structure
      if (!this._validateSessionStructure(session)) {
        await this.clearSession();
        return false;
      }

      // Continue with remaining validation
      const isValidSession = (

Since we can only see part of the code, you'll need to:

  1. Continue breaking down the remaining validation logic inside the isValidSession check into separate private methods based on what's being validated (e.g., _validateTradingAccounts(), _validateUserProfile(), etc.)
  2. Each validation method should return a boolean and handle one specific aspect of validation
  3. The main validateSession method should orchestrate these individual validations

The naming and exact structure of additional validation methods will depend on what's being validated in the rest of the isValidSession check that we can't see.

if (mounted) {
setIsAuthenticated(isAuth);
}
} catch (error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Consider enhancing error handling to preserve error context

Instead of just returning false, consider throwing a custom error with the original error as cause. This would help with debugging and error reporting while maintaining the boolean return type for the public API.

console.error('Failed to switch account:', error);
return false;
} finally {
setTimeout(() => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Avoid hardcoded delay in switchAccount

The 300ms delay seems arbitrary. Consider making this configurable or removing it if not strictly necessary. If it's needed for UI purposes, document the reason.

Suggested implementation:

      setTimeout(() => {
        setIsSwitchingAccount(false);
      }, ACCOUNT_SWITCH_TRANSITION_DELAY);

At the top of src/hooks/useAuth.js, add this constant:

// Delay (in ms) to ensure UI state updates complete and animations finish smoothly
// when switching between accounts. This prevents visual glitches during the transition.
const ACCOUNT_SWITCH_TRANSITION_DELAY = 300;

If this delay is used in other components or might need to be adjusted based on the environment, consider moving it to a shared constants file (e.g., src/constants/ui.js) where it can be imported where needed.

let attempts = 0;
const maxAttempts = 40; // 2 seconds total

const checkState = () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Potential race condition in OAuth callback state updates

The state check loop could miss updates or continue running after component unmount. Consider using a more robust approach like React's useEffect or a proper state management solution.

error.details = errorResponse.details;

// Map error codes to specific error types
switch (error.code) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Expand error code handling in BaseApi

Consider adding more error codes to handle common API scenarios like network errors, server errors, and timeout conditions. This would improve error reporting and handling throughout the application.

Suggested implementation:

    switch (error.code) {
      // Authentication & Authorization
      case 'AuthorizationRequired':
        error.name = 'AuthorizationError';
        break;
      case 'InvalidAppID':
        error.name = 'ConfigurationError';
        break;
      case 'InputValidationFailed':
        error.name = 'ValidationError';
        break;
      case 'PermissionDenied':
        error.name = 'PermissionError';
        break;

      // Network Related
      case 'NetworkError':
        error.name = 'NetworkError';
        break;
      case 'ConnectionLost':
        error.name = 'NetworkError';
        break;
      case 'RequestTimeout':
        error.name = 'TimeoutError';
        break;

      // Server Errors
      case 'InternalServerError':
        error.name = 'ServerError';
        break;
      case 'ServiceUnavailable':
        error.name = 'ServerError';
        break;
      case 'MaintenanceError':
        error.name = 'ServerError';
        break;

      // Rate Limiting
      case 'RateLimit':
        error.name = 'RateLimitError';
        break;
      case 'ConcurrentRequestLimit':
        error.name = 'RateLimitError';
        break;

      // Data Related
      case 'ResourceNotFound':
        error.name = 'NotFoundError';
        break;
      case 'DataError':
        error.name = 'DataProcessingError';
        break;

The developer should also:

  1. Update any error handling documentation to reflect these new error types
  2. Ensure error handling middleware/catch blocks can handle these new error types appropriately
  3. Consider adding specific error handling logic for each new error type where they are caught

import { useTelegram, useAuth } from '@/hooks';
import styles from './LoginPage.module.css';

const LoginPage = () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the OAuth callback handling into a custom hook and simplifying the LoginPage useEffect to make auth state transitions more explicit.

The login flow can be simplified by extracting the OAuth callback handling and using a more declarative state approach. Here's how:

  1. Create a custom hook for OAuth:
const useOAuthCallback = () => {
  const { handleOAuthCallback } = useAuth();
  const location = useLocation();

  const [state, setState] = useState({ 
    success: false, 
    processing: false 
  });

  useEffect(() => {
    if (!location.search) return;

    const processOAuth = async () => {
      setState({ success: false, processing: true });
      const searchParams = new URLSearchParams(location.search);
      const success = await handleOAuthCallback(searchParams);
      setState({ success, processing: false });
    };

    processOAuth();
  }, [location.search, handleOAuthCallback]);

  return state;
};
  1. Simplify LoginPage useEffect:
const LoginPage = () => {
  const { isLoading, isAuthenticated, initialize } = useAuth();
  const { success, processing } = useOAuthCallback();
  const [initializing, setInitializing] = useState(false);

  useEffect(() => {
    const init = async () => {
      if (sessionStorage.getItem('logout_in_progress')) {
        sessionStorage.removeItem('logout_in_progress');
        return;
      }

      setInitializing(true);
      await initialize();
      setInitializing(false);
    };

    init();
  }, [initialize]);

  useEffect(() => {
    if (success || isAuthenticated) {
      navigate(ROUTES.DASHBOARD, { replace: true });
    }
  }, [success, isAuthenticated, navigate]);

  if (isLoading || processing || initializing) {
    return <LoadingSpinner text="Processing login..." />;
  }
  // ... rest of render code
};

This approach:

  • Separates OAuth handling into its own hook
  • Removes nested conditionals
  • Makes the auth state transitions more explicit
  • Maintains all existing functionality
  • Reduces cognitive load by isolating concerns


class AuthService {
constructor() {
this.storageKey = 'auth_session';
this.tradingAccountsKey = 'trading_accounts';
this.defaultAccountKey = 'default_account';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Avoid hardcoding the default account key.

Storing the default account key directly in the code can pose a security risk. Consider storing it securely, such as in a secrets management service or environment variables, and retrieving it during runtime.

let account = existingAccount;

if (!existingAccount && success) {
account = {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Avoid hardcoding the token.

Hardcoding tokens directly in the code can be a security vulnerability. Consider using a more secure approach, such as fetching the token from a secrets management service or environment variables.

if (!existingAccount && success) {
account = {
account: `TG${telegramUser.id}`,
token: 'telegram-token',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Avoid hardcoding the token.

Storing tokens directly within the code is not recommended. Explore more secure options like environment variables or a dedicated secrets management service.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant