DEV Community

Cover image for How to implement Google OAuth in browser extension without “Tabs” permission
Himanshu
Himanshu

Posted on

How to implement Google OAuth in browser extension without “Tabs” permission

Handling authentication, specifically Google OAuth is a nightmare inside extension. You have to deal with chrome.identity, redirect URIs, and complex manifest.json configurations.

But there’s a super simple way and nobody is talking about it…

Here’s how i implemented google OAuth and classic email and password signup inside my chrome extension without tabs permission, yupp you heard it right, it's possible with just ‘storage’ permission

The Architecture: How It Works

We aren't doing authentication inside the extension (bcz popup page can close in the middle of flow).
We are doing it on our website and passing the session token to the extension.

i used NextJS to create a web app but you can use react + vite too, and for auth i used supabase but you can use firebase or any other auth provider, make sure they give you user’s session tokens:

Here's the flow:

  1. User installs the extension and is redirected to your web app's signup page
  2. User authenticates using Google OAuth or email/password on the web app
  3. After successful authentication, tokens are exposed in hidden DOM elements
  4. A content script running on your web app extracts these tokens
  5. Tokens are sent to the extension's background script
  6. Background script creates a session and stores it locally
  7. Now both your extension and web app are in sync

Setting up the Web App

First, we need an authentication context in our Next.js application to handle the user's state. We are using Supabase's onAuthStateChange listener to detect when a user logs in.

Step 1: The Auth Context

Create contexts/AuthContext.tsx. This provider manages the user session and makes it available throughout your app.

/// contexts/AuthContext.tsx

'use client';

import React, { createContext, useContext, useEffect, useState } from 'react';
import supabase from '@/lib/supabase/supabaseClient';

type AuthContextType = {
  user: SupabaseUser | undefined;
  session: SupabaseSession | undefined;
  loading: boolean;
  logout: () => Promise<{ error: Error | null }>;
};

type AuthProviderProp = {
  children: React.ReactNode;
};

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

export function AuthProvider({ children }: AuthProviderProp) {
  const [user, setUser] = useState<SupabaseUser | undefined>(undefined);
  const [session, setSession] = useState<SupabaseSession | undefined>(undefined);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    supabase.auth.onAuthStateChange((eventName, session) => {
      console.log('Auth state changed:', eventName, session);

      if (eventName === 'INITIAL_SESSION' && session?.user) {
        setUser(session.user);
        setSession(session);
      } else if (eventName === 'SIGNED_IN' && session?.user) {
        setUser(session.user);
        setSession(session);
      } else if (eventName === 'SIGNED_OUT') {
        setUser(undefined);
        setSession(undefined);
      }
    });
  }, []);

  const value = {
    user,
    session,
    loading,
    logout: () => supabase.auth.signOut(),
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
Enter fullscreen mode Exit fullscreen mode

The onAuthStateChange listener is crucial here. It fires whenever the authentication state changes, allowing us to update our UI and expose tokens when the user successfully logs in.

Step 2: Wrap Your app or main.tsx

Make sure to wrap your app app/layout.tsx with AuthProvider so the entire app has access to the user state. Like this:

/// app/layout.tsx
import { AuthProvider } from '@/context/AuthContext';

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <AuthProvider>
          {children}
         </AuthProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Create the Authentication Page

Now let's build the actual authentication page where users will sign up or log in.

// app/auth/page.tsx
'use client';

import { useState } from 'react';
import supabase from '@/lib/supabase/supabaseClient';

export default function AuthPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleRegister() {
    setLoading(true);
    const { data, error } = await supabase.auth.signUp({
      email: email.trim(),
      password: password,
      ///optional
      options: {
        emailRedirectTo: process.env.NEXT_PUBLIC_AUTH_SUCCESS_URL, 
      },
    });

    setLoading(false);

    if (error) {
      console.error('Registration error:', error);
      // Handle error (show toast, etc.)
    }
  }

  async function handleGoogleLogin() {
    await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: process.env.NEXT_PUBLIC_AUTH_SUCCESS_URL,
        queryParams: {
          access_type: 'offline',
          prompt: 'consent',
        },
      },
    });
  }

  return (
    <div className="auth-container">
      <h1>Sign Up for Your Extension</h1>

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />

      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />

      <button onClick={handleRegister} disabled={loading}>
        Sign Up with Email
      </button>

      <button onClick={handleGoogleLogin}>
        Continue with Google
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Expose Tokens via Hidden DOM Elements

This is the bridge between your web app and extension. Add hidden elements to your footer (or any component) that will contain the authentication tokens.

// components/Footer.tsx
'use client';

import { useAuth } from '@/contexts/AuthContext';

const Footer = () => {
  const { session, user } = useAuth();

  return (
    <>
      {/* Hidden elements for extension's content script to read */}
      {session && (
        <div className="hidden">
          <div className="userEmailDiv">{user?.email}</div>
          <div className="accessTokenDiv">{session?.access_token}</div>
          <div className="refreshTokenDiv">{session?.refresh_token}</div>
        </div>
      )}

      {/* Rest of your footer content */}
      <footer>
        {/* Your footer content */}
      </footer>
    </>
  );
};

export default Footer;
Enter fullscreen mode Exit fullscreen mode

The hidden class ensures these elements are hidden visually but remain in the DOM for the extension to access.

Building the Chrome Extension

Step 5: Create a Content Script to Extract Tokens

The content script runs on your web app's domain and watches for the authentication tokens to appear,
I'm using the WXT framework, but you can do the same with a plain content script too. Just make sure this script runs on your web app, bcz it will extract the supabase tokens from the footer of your website:

// authContentScript.ts
export default defineContentScript({
  matches: ['https://your-web-app.com/*'], // Replace with your domain
  allFrames: true,
  runAt: 'document_idle',
  main() {
    console.log('Auth content script initialized');

    function observeAuthTokens() {
      const observer = new MutationObserver((mutationList, observer) => {
        const accessTokenDiv = document.querySelector('.accessTokenDiv');

        if (accessTokenDiv) {
          const userEmailDiv = document.querySelector('.userEmailDiv');
          const refreshTokenDiv = document.querySelector('.refreshTokenDiv');

          if (accessTokenDiv.textContent?.trim() && refreshTokenDiv?.textContent?.trim()) {
            const accessToken = accessTokenDiv.textContent;
            const refreshToken = refreshTokenDiv.textContent;

            sendAuthTokens(accessToken, refreshToken);
            observer.disconnect();
          }
        }
      });

      observer.observe(document.body, {
        attributes: false,
        childList: true,
        subtree: true,
        characterData: true,
      });
    }

    observeAuthTokens();

    async function sendAuthTokens(access_token: string, refresh_token: string) {
      try {
        const response = await browser.runtime.sendMessage({
          type: 'signup',
          accessToken: access_token,
          refreshToken: refresh_token,
        });

        console.log('✅ Tokens sent to background script:', response);
      } catch (error) {
        console.error('Error sending tokens:', error);
      }
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

The MutationObserver watches for changes in the DOM. Once it detects the token elements are populated (in you web app’s footer or wherever you are exposing them), then it extracts them and sends them to the background script.

Step 6: Handle Authentication in Background Script

The background script receives the tokens and creates a Supabase session within the extension.

// backgroundScript.ts
import supabase from '@/lib/SupabaseClient';

type TMessage = {
  type: string;
  accessToken?: string;
  refreshToken?: string;
};

async function handleMessages(
  message: TMessage,
  sender: any,
  sendResponse: any
) {
  if (message.type === 'signup') {
    try {
      const { data, error } = await supabase.auth.setSession({
        access_token: message.accessToken!,
        refresh_token: message.refreshToken!,
      });

      if (error) {
        console.error('Session creation error:', error);
        sendResponse({ session: null, error: error.message });
        return;
      }

      // Store session in local storage
      await browser.storage.local.set({ session: data.session });

      sendResponse({ session: data.user });
    } catch (error) {
      console.error('Error in handleMessages:', error);
      sendResponse({ session: null, error: 'Unknown error' });
    }
  }
}

// Listen for messages from content scripts
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
  handleMessages(request, sender, sendResponse);
  return true; // Keep channel open for async response
});
Enter fullscreen mode Exit fullscreen mode

Step 7: Create Extension Authentication Context

Finally, create an authentication context for your extension's popup and other components.

// contexts/AuthContext.tsx (Extension, not your web app)
import React, { createContext, useContext, useEffect, useState } from 'react';
import supabase from '../lib/SupabaseClient';

type AuthContextType = {
  user: SupabaseUser | undefined;
};

type AuthProviderProp = {
  children: React.ReactNode;
};

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

export const AuthProvider = ({ children }: AuthProviderProp) => {
  const [user, setUser] = useState<SupabaseUser | undefined>(undefined);

  useEffect(() => {
    async function initializeAuth() {
      // Check for stored session
      const { session } = await browser.storage.local.get('session');

      if (session && !user) {
        try {
          const { data, error } = await supabase.auth.setSession(session);

          if (data) {
            console.log('✅ Session restored:', data);
            await browser.storage.local.remove('session');
            setUser(data.user);
          }

          if (error) {
            console.error('Session restoration error:', error);
          }
        } catch (error) {
          console.error('Error initializing auth:', error);
        }
      }
    }

    initializeAuth();

    // Listen for auth state changes
    supabase.auth.onAuthStateChange((event, session) => {
      console.log('Extension auth state:', event, session);

      if (event === 'INITIAL_SESSION' && session?.user) {
        setUser(session.user);
      } else if (event === 'SIGNED_IN' && session?.user) {
        setUser(session.user);
      } else if (event === 'SIGNED_OUT') {
        setUser(undefined);
      }
    });
  }, []);

  const value = {
    user,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
Enter fullscreen mode Exit fullscreen mode

Make sure to wrap your entire extension popup or app page app.tsx with AuthProvider so the entire popup has access to the user state. Like this:

/// App.tsx (extension, not web app)

import { AuthProvider } from '@/context/AuthContext';

export function App({ children }: AppProvidersProp) {
  return (
     <AuthProvider>
        {children}
     </AuthProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

now whenever user open your extension's popup after signing up on your web app, this above AuthProvidor/useEffect/initializeAuth() will run and user automatically logged in in your extension.

phew

that's a lot of code, i know.

well if you don't want to build all this yourself, i actually created a Production-Ready extension boilerplate that contains auth, subscription, forget password, contact form and everything you need to ship and monetize extension fast, you can check it out here: extFast.web.app

thank you sooo much for reading, that's my first blog ❤️

have a nice day,

bye bye :)

Top comments (0)