如何在浏览器扩展中实现 Google OAuth 而无需 “Tabs” 权限
Source: Dev.to
架构:工作原理
我们并不在扩展内部进行认证(因为弹出页可能在流程中途关闭)。
相反,我们在自己的网站上处理认证,并将会话令牌传递给扩展。
流程
- 用户安装扩展后被重定向到你的 Web 应用的注册页面。
- 用户在 Web 应用上使用 Google OAuth 或邮箱/密码进行认证。
- 认证成功后,令牌会以隐藏的 DOM 元素形式暴露出来。
- 在你的 Web 应用上运行的内容脚本会提取这些令牌。
- 令牌被发送到扩展的后台脚本。
- 后台脚本创建会话并将其本地存储。
- 扩展和 Web 应用现在保持同步。
设置 Web 应用
首先,在你的 Next.js(或 React + Vite)项目中创建一个认证上下文,用来管理用户状态。下面我们使用 Supabase 的 onAuthStateChange 监听器来检测用户登录。
步骤 1:认证上下文
创建 contexts/AuthContext.tsx。该提供者管理用户会话并在整个应用中提供。
// 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;
};
type AuthProviderProp = {
children: React.ReactNode;
};
const AuthContext = createContext(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(undefined);
const [session, setSession] = useState(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 {children};
}
onAuthStateChange 监听器会在认证状态变化时触发,帮助我们在用户成功登录后更新 UI 并暴露令牌。
步骤 2:为应用套上包装
在你的应用(例如 app/layout.tsx)外层包裹 AuthProvider,使整个应用都能访问用户状态。
// app/layout.tsx
import { AuthProvider } from '@/contexts/AuthContext';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
步骤 3:创建认证页面
编写用户注册或登录的页面。
// 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,
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 (
## Sign Up for Your Extension
setEmail(e.target.value)}
placeholder="Email"
/>
setPassword(e.target.value)}
placeholder="Password"
/>
Sign Up with Email
Continue with Google
);
}
步骤 4:通过隐藏的 DOM 元素暴露令牌
在页面(例如页脚)中加入隐藏元素,存放认证令牌。扩展的内容脚本会读取这些元素。
// components/Footer.tsx
'use client';
import { useAuth } from '@/contexts/AuthContext';
const Footer = () => {
const { session, user } = useAuth();
return (
<>
{/* Hidden elements for the extension's content script */}
{session && (
{user?.email}
{session?.access_token}
{session?.refresh_token}
)}
{/* Your regular footer content */}
{/* Footer content */}
);
};
export default Footer;
sr-only 类(仅屏幕阅读器可见)使这些元素在视觉上不可见,但仍然存在于 DOM 中,供扩展访问。
构建 Chrome 扩展
步骤 5:创建内容脚本以提取令牌
内容脚本在你的 Web 应用域名下运行,监听隐藏的令牌元素并将令牌转发给后台脚本。下面是使用 WXT 框架的示例(你也可以改写为普通的内容脚本)。
// 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(() => {
const accessTokenEl = document.querySelector('.accessTokenDiv');
const refreshTokenEl = document.querySelector('.refreshTokenDiv');
const emailEl = document.querySelector('.userEmailDiv');
if (accessTokenEl && refreshTokenEl && emailEl) {
const tokens = {
accessToken: accessTokenEl.textContent,
refreshToken: refreshTokenEl.textContent,
email: emailEl.textContent,
};
// Send tokens to background script
chrome.runtime.sendMessage({ type: 'SET_TOKENS', payload: tokens });
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
observeAuthTokens();
},
});
脚本监视 DOM 变化,在令牌出现时提取其值,并通过 chrome.runtime.sendMessage 将其发送给扩展的后台脚本,随后可以使用 storage 权限将会话存储下来。