Skip to content

Deliquified/mini-app-oauth

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

1 Commit
Β 
Β 

Repository files navigation

Twitter OAuth with LUKSO Universal Profiles in Mini-Apps: A Step-by-Step Guide

πŸš€ Introduction

When building mini-apps on LUKSO, you'll encounter a significant challenge: mini-apps run in iframes, but OAuth authentication (like Twitter login) doesn't work properly in iframes. This guide provides a complete solution by implementing a popup-based authentication flow.

The Problem: Mini-apps on LUKSO run within iframes under domains like universaleverything.io, but OAuth redirects don't work in iframes.

Our Solution: A popup window approach that maintains connection between the user's Universal Profile wallet and their Twitter account.

πŸ“‹ Prerequisites

Before starting, make sure you have:

Required packages:

  • next-auth (for Twitter authentication)
  • firebase-admin (for database storage)
  • @lukso/up-provider (for Universal Profile integration)

πŸ› οΈ Implementation Guide

Step 1: Set Up Firebase Configuration

First, create a Firebase service account and set up the Firebase configuration. Create a file at src/lib/firebase.ts:

// lib/firebase.ts
import * as admin from 'firebase-admin';

// Check if already initialized to prevent multiple initializations
if (!admin.apps.length) {
  try {
    // Parse the service account from a JSON string in environment variable
    const serviceAccount = JSON.parse(
      process.env.FIREBASE_SERVICE_ACCOUNT_KEY || '{}'
    );

    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
      databaseURL: `https://${process.env.FIREBASE_PROJECT_ID}.firebaseio.com`
    });
    console.log('Firebase Admin initialized successfully');
  } catch (error) {
    console.error('Firebase Admin initialization error:', error);
    throw new Error(`Firebase initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
  }
}

// Export the Firestore instance
const db = admin.firestore();
export { db };

Step 2: Create API Endpoint to Save Twitter Data

Create an API endpoint to store Twitter data in Firebase. Create a file at src/app/api/save-twitter/route.ts:

// app/api/save-twitter/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/firebase';
import { FieldValue } from 'firebase-admin/firestore';

interface TwitterData {
  walletAddress: string;
  twitterId: string;
  twitterUsername: string;
  twitterHandle?: string;
  twitterImage?: string;
  canBeRoasted?: boolean;
}

interface SuccessResponse {
  success: true;
  message: string;
}

interface ErrorResponse {
  success: false;
  error: string;
}

export async function POST(request: NextRequest): Promise<NextResponse> {
  try {
    const body: TwitterData = await request.json();

    const { 
      walletAddress, 
      twitterId, 
      twitterUsername, 
      twitterHandle, 
      twitterImage, 
      canBeRoasted = true 
    } = body;

    // Validate input
    if (!walletAddress || !twitterId || !twitterUsername) {
      return NextResponse.json(
        { success: false, error: 'Missing required fields' } as ErrorResponse,
        { status: 400 }
      );
    }

    if (!walletAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
      return NextResponse.json(
        { success: false, error: 'Invalid wallet address format' } as ErrorResponse,
        { status: 400 }
      );
    }

    const normalizedAddress = walletAddress.toLowerCase();
    const userDocRef = db.collection('users').doc(normalizedAddress);

    await userDocRef.set(
      {
        walletAddress: normalizedAddress,
        twitterId,
        twitterUsername,
        twitterHandle: twitterHandle || twitterUsername,
        twitterImage: twitterImage || null,
        canBeRoasted,
        createdAt: FieldValue.serverTimestamp(), // Use server timestamp
      },
      { merge: true }
    );

    return NextResponse.json({
      success: true,
      message: 'Twitter account connected successfully'
    } as SuccessResponse);
  } catch (error) {
    console.error('Error saving Twitter account:', error);
    return NextResponse.json({
      success: false,
      error: error instanceof Error ? error.message : 'Failed to save Twitter data'
    } as ErrorResponse, { status: 500 });
  }
}

Step 3: Create API Endpoint to Fetch User Data

Create an API endpoint to retrieve the linked Twitter data. Create a file at src/app/api/user/route.ts:

// app/api/user/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/firebase';
import * as admin from 'firebase-admin';

interface SuccessResponse {
  success: true;
  exists: boolean;
  data: admin.firestore.DocumentData | null;
  isRoastable?: boolean;
}

interface ErrorResponse {
  success: false;
  error: string;
}

export async function GET(request: NextRequest): Promise<NextResponse> {
  const searchParams = request.nextUrl.searchParams;
  const walletAddress = searchParams.get('address');

  if (!walletAddress) {
    return NextResponse.json(
      { success: false, error: 'Wallet address is required' } as ErrorResponse,
      { status: 400 }
    );
  }

  if (!walletAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
    return NextResponse.json(
      { success: false, error: 'Invalid wallet address format' } as ErrorResponse,
      { status: 400 }
    );
  }

  try {
    const normalizedAddress = walletAddress.toLowerCase();
    const docRef = db.collection('users').doc(normalizedAddress);
    const docSnap = await docRef.get();

    if (docSnap.exists) {
      return NextResponse.json({
        success: true,
        exists: true,
        data: docSnap.data(),
        isRoastable: docSnap.data()?.canBeRoasted || false
      } as SuccessResponse);
    } else {
      return NextResponse.json({
        success: true,
        exists: false,
        data: null
      } as SuccessResponse);
    }
  } catch (error) {
    console.error('API route error:', error);
    return NextResponse.json({
      success: false,
      error: error instanceof Error ? error.message : 'Unknown error'
    } as ErrorResponse, { status: 500 });
  }
}

Step 4: Set Up NextAuth Configuration

Create the NextAuth configuration. Create a file at src/utils/authOptions.ts:

// utils/authOptions.ts
import { AuthOptions } from "next-auth";
import TwitterProvider from "next-auth/providers/twitter";

export const authOptions: AuthOptions = {
  providers: [
    TwitterProvider({
      clientId: process.env.TWITTER_CLIENT_ID || "",
      clientSecret: process.env.TWITTER_CLIENT_SECRET || "",
      version: "2.0",
    }),
  ],
  callbacks: {
    async jwt({ token, account, profile }) {
      if (account) {
        token.id = account.providerAccountId;
        token.accessToken = account.access_token;
      }
      if (profile) {
        token.twitterHandle = profile.data.username;
        token.image = profile.data.profile_image_url;
        token.email = profile.data.email;
      }
      return token;
    },
    async session({ session, token }) {
      session.user.id = token.id;
      session.user.twitterHandle = token.twitterHandle;
      session.user.image = token.image;
      session.user.email = token.email; 
      return session;
    },
    async redirect({ url, baseUrl }) {
      // Redirect to the authenticatedX page after successful login
      return `${baseUrl}/authenticatedX`;
    },
  },
  pages: {
    signIn: "/signin", // Custom sign-in page
  },
};

Step 5: Create NextAuth API Route

Create the NextAuth API route. Create a file at src/app/api/auth/[...nextauth]/route.ts:

// app/api/auth/[...nextauth]/route.ts
import { authOptions } from "@/utils/authOptions";
import NextAuth, { AuthOptions } from "next-auth";

const handler = NextAuth(authOptions as AuthOptions);
export { handler as GET, handler as POST };

Step 6: Create Sign-In Page

Create a simple sign-in page that redirects to Twitter. Create a file at src/app/signin/page.tsx:

// app/signin/page.tsx
"use client";

import { useEffect } from "react";
import { signIn } from "next-auth/react";

export default function SignIn() {
  useEffect(() => {
    signIn("twitter", { callbackUrl: "/authenticatedX" });
  }, []);

  return (
    <div className="flex flex-col items-center justify-center min-h-screen p-4">
      <h1 className="text-2xl font-bold mb-4">Redirecting to Twitter...</h1>
      <p className="mb-6">Please wait while we redirect you to Twitter for authentication.</p>
      <div className="w-12 h-12 border-4 border-blue-400 border-t-blue-600 rounded-full animate-spin"></div>
    </div>
  );
}

Step 7: Create Authentication Page

Create the authentication page that handles the Twitter callback and saves the data. Create a file at src/app/authenticatedX/page.tsx:

// app/authenticatedX/page.tsx
"use client";

import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";

// Create a separate component for the authentication content
export default function AuthenticatedX() {
  const { data: session, status } = useSession();
  const searchParams = useSearchParams();
  const [saving, setSaving] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  const [saveSuccess, setSaveSuccess] = useState(false);
  const [initialRedirectDone, setInitialRedirectDone] = useState(false);

  // Get wallet from URL parameter
  const walletFromUrl = searchParams.get('wallet');
  
  // Get wallet from localStorage if it exists
  const [walletFromStorage, setWalletFromStorage] = useState<string | null>(null);
  
  // Initialize walletFromStorage from localStorage
  useEffect(() => {
    try {
      const storedWallet = localStorage.getItem('twitter_auth_wallet');
      setWalletFromStorage(storedWallet);
    } catch (e) {
      console.error('Error accessing localStorage:', e);
    }
  }, []);

  // Handle initial redirect to Twitter auth
  useEffect(() => {
    if (walletFromUrl && !initialRedirectDone) {
      try {
        // Store the wallet in localStorage
        localStorage.setItem('twitter_auth_wallet', walletFromUrl);
        setWalletFromStorage(walletFromUrl);
        setInitialRedirectDone(true);
        
        // Redirect to Twitter auth after a short delay
        setTimeout(() => {
          const authUrl = `/api/auth/signin/twitter?callbackUrl=${encodeURIComponent('/authenticatedX')}`;
          window.location.href = authUrl;
        }, 100);
      } catch (error) {
        console.error("Error during wallet storage:", error);
        setSaveError("Failed to store wallet address. Please try again.");
      }
    }
  }, [walletFromUrl, initialRedirectDone]);

  // Save Twitter data when we have both session and wallet
  useEffect(() => {
    const saveTwitterData = async () => {
      // Only proceed if we have both authenticated session and wallet address
      if (status !== "authenticated" || !session?.user || saving || saveSuccess || !walletFromStorage) {
        return;
      }

      try {
        setSaving(true);
        setSaveError(null);

        const response = await fetch('/api/save-twitter', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            walletAddress: walletFromStorage,
            twitterId: session.user.id,
            twitterUsername: session.user.name,
            twitterHandle: session.user.twitterHandle,
            twitterImage: session.user.image,
            canBeRoasted: true // You can customize this based on your needs
          }),
        });

        const result = await response.json();

        if (!response.ok) {
          throw new Error(result.error || `HTTP error! status: ${response.status}`);
        }

        if (!result.success) {
          throw new Error(result.error || 'Operation failed');
        }

        // Success! Clear wallet from localStorage and close window
        setSaveSuccess(true);
        localStorage.removeItem('twitter_auth_wallet');
        setTimeout(() => window.close(), 2000);

      } catch (error) {
        console.error("Error saving Twitter data:", error);
        setSaveError(
          error instanceof Error 
            ? error.message 
            : 'Failed to save Twitter data. Please try again.'
        );
      } finally {
        setSaving(false);
      }
    };

    saveTwitterData();
  }, [session, status, saving, saveSuccess, walletFromStorage]);

  // Render different UI states based on the current status
  return (
    <div className="flex flex-col items-center justify-center min-h-screen p-4">
      {/* Initial loading with wallet from URL */}
      {walletFromUrl && !walletFromStorage && (
        <>
          <h1 className="text-2xl font-bold mb-4">Connecting to Twitter...</h1>
          <div className="w-12 h-12 border-4 border-blue-400 border-t-blue-600 rounded-full animate-spin"></div>
        </>
      )}

      {/* Authenticating with Twitter */}
      {walletFromStorage && status === "loading" && (
        <>
          <h1 className="text-2xl font-bold mb-4">Authenticating with Twitter...</h1>
          <div className="w-12 h-12 border-4 border-blue-400 border-t-blue-600 rounded-full animate-spin"></div>
        </>
      )}

      {/* Saving data after authentication */}
      {status === "authenticated" && saving && (
        <>
          <h1 className="text-2xl font-bold mb-4">Linking your Twitter account...</h1>
          <div className="w-12 h-12 border-4 border-blue-400 border-t-blue-600 rounded-full animate-spin"></div>
        </>
      )}

      {/* Success state */}
      {saveSuccess && (
        <>
          <h1 className="text-2xl font-bold mb-4 text-green-600">Successfully Linked!</h1>
          <p>Your Twitter account has been linked to your Universal Profile.</p>
          <p className="mt-2 text-sm text-gray-500">This window will close automatically...</p>
        </>
      )}

      {/* Error state */}
      {saveError && (
        <>
          <h1 className="text-2xl font-bold mb-4 text-red-600">Error</h1>
          <p className="text-red-500">{saveError}</p>
          <button 
            className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
            onClick={() => window.close()}
          >
            Close Window
          </button>
        </>
      )}
    </div>
  );
}

Step 8: Add Authentication Button to Your Mini-App

Now, add a button to your mini-app to initiate the Twitter authentication:

// In your main app component
"use client";
import { useState } from 'react';
import { useUpProvider } from '@/components/upProvider'; // Import from your UP Provider

export default function YourMiniApp() {
  const [twitterAuthLoading, setTwitterAuthLoading] = useState(false);
  const { contextAccounts } = useUpProvider(); // Get the wallet address from UP Provider
  
  const handleTwitterAuth = () => {
    setTwitterAuthLoading(true);
    
    // Get the Universal Profile address
    const walletAddress = contextAccounts?.[0] || '';
    
    // Calculate window position for center of screen
    const width = 600;
    const height = 700;
    const left = window.innerWidth / 2 - width / 2;
    const top = window.innerHeight / 2 - height / 2;
    
    // Open popup window for Twitter auth
    window.open(
      `/authenticatedX?wallet=${encodeURIComponent(walletAddress)}`,
      'Twitter Authentication',
      `width=${width},height=${height},left=${left},top=${top}`
    );
    
    // Add event listener to detect when popup closes
    window.addEventListener('message', (event) => {
      if (event.data === 'twitter_auth_complete') {
        setTwitterAuthLoading(false);
        // Refresh your UI or fetch updated data
      }
    });
  };
  
  return (
    <div>
      <h1>My LUKSO Mini-App</h1>
      <button 
        onClick={handleTwitterAuth}
        disabled={twitterAuthLoading}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
      >
        {twitterAuthLoading ? "Connecting..." : "Connect Twitter Account"}
      </button>
    </div>
  );
}

Step 9: Add Environment Variables

Create a .env.local file in your project root with the following variables:

# Twitter API credentials
TWITTER_CLIENT_ID=your_twitter_client_id
TWITTER_CLIENT_SECRET=your_twitter_client_secret

# Firebase configuration
FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"your-project-id",...}
FIREBASE_PROJECT_ID=your-project-id

# NextAuth configuration
NEXTAUTH_URL=your_app_url
NEXTAUTH_SECRET=your_nextauth_secret

πŸ”„ How It Works: Step-by-Step Walkthrough

Now let's walk through the entire flow to understand how the pieces work together:

1. User Initiates Authentication

When a user clicks "Connect Twitter Account" in your mini-app:

  • The handleTwitterAuth function gets the host's Universal Profile address (contextAccounts[0]). If you want to save visitor's Twitter you'd pass accounts[0]
  • Opens a popup window and passes the address as a URL parameter

2. Popup Stores the Wallet Address

In the /authenticatedX page:

  • The popup extracts the wallet address from the URL
  • Stores it in localStorage for safekeeping (because iframe localStorage is isolated from popup localStorage)
  • Then redirects to Twitter authentication

3. Twitter Authentication Process

  • User authenticates with Twitter
  • Twitter redirects back to your callback URL (/authenticatedX)
  • NextAuth handles the OAuth flow and creates a session with Twitter data

4. Saving the Connection

Back in the /authenticatedX page after Twitter auth:

  • The page now has access to both:
    • Twitter data (from NextAuth session)
    • Universal Profile address (from localStorage)
  • The page sends both to your API endpoint (/api/save-twitter)
  • The API saves this connection in Firebase
  • The popup window closes automatically

5. Data Retrieval

Later, to retrieve the connected Twitter account:

  • Call your API endpoint (/api/user?address=0x123...)
  • The API looks up the document in Firebase using the wallet address
  • Returns the connected Twitter data

Alternatively, you could auto-fetch connected Twitter account after 10-15 seconds because that's how long authentication via X will take, but in case user is authenticated faster for best UX you could track if user is authenticating via useState (userClickedXLogin) and if it's true, your Login button would fetch the API endpoint. Reason we do this is because we don't want to complicated things by trying to communicate between the pop-up and parent window.

πŸ” Why This Approach Works for Mini-Apps

This solution works around the iframe limitations because:

  1. Isolation Bypass: The popup window runs on your domain, not in the iframe
  2. State Preservation: localStorage in the popup maintains the wallet address during the OAuth flow
  3. Seamless UX: The popup handles everything automatically and closes when done
  4. Data Persistence: The wallet ↔ Twitter connection is stored in Firebase, retrievable from any context

🚧 Common Issues and Troubleshooting

Popup Blocked

If the popup is blocked by the browser:

  • Add a fallback link that opens in a new tab
  • Inform users to allow popups for your site

Local Storage Issues

If localStorage access fails:

  • Check for cross-origin or privacy settings issues
  • Consider adding a fallback method like URL parameters

Authentication Fails

If Twitter authentication fails:

  • Verify your Twitter API credentials
  • Check the callback URL in your Twitter developer dashboard
  • Ensure the NextAuth configuration is correct

πŸ” Security Considerations

  • Data Validation: Always validate wallet addresses before saving them
  • Environment Variables: Keep API keys and secrets in environment variables
  • JWT Security: Set a strong NEXTAUTH_SECRET for secure sessions
  • Error Handling: Implement proper error handling throughout the flow

IMPORTANT

This is not a foolproof setup for the following reason: the address is stored in localstorage as a string. This means a malicious script could replace user's address with another one. This would cause your database to save user's Twitter account to the wrong address causing UI/UX issues. For best practice, we recommend setting up server side signature scheme. First - when visiting AuthenticatedX/page.tsx the URL should contain timestamped signed message with expiration date that matches your server side private key's public address. This ensures that the Twitter authentication page is only viewable via the Login flow. Second - instead of storing the address directly as a string, sign it with the server (timestamped as well) and later validate that the signature is yours and has not expired.

πŸ“ Complete Project Structure

src/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”‚   └── [...nextauth]/
β”‚   β”‚   β”‚       └── route.ts
β”‚   β”‚   β”œβ”€β”€ save-twitter/
β”‚   β”‚   β”‚   └── route.ts
β”‚   β”‚   └── user/
β”‚   β”‚       └── route.ts
β”‚   β”œβ”€β”€ authenticatedX/
β”‚   β”‚   └── page.tsx
β”‚   β”œβ”€β”€ signin/
β”‚   β”‚   └── page.tsx
β”‚   └── page.tsx (your mini-app main page)
β”œβ”€β”€ components/
β”‚   └── upProvider.tsx (from LUKSO template)
β”œβ”€β”€ lib/
β”‚   └── firebase.ts
└── utils/
    └── authOptions.ts

πŸ“š Resources

By following this guide, you can successfully implement Twitter OAuth authentication in your LUKSO mini-apps, allowing users to connect their social media accounts to their Universal Profiles despite the iframe limitations of the platform.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published