无后端React应用调用Google Tasks API遇401未授权错误求助
问题描述
我正在开发一个仅使用React和JavaScript的无后端任务Web应用,用户可通过Google账号登录并调用Google Tasks API实现高级搜索功能。但应用尝试获取任务列表时,出现401 Unauthorized错误,访问tasks.googleapis.com的GET请求也返回该错误。
错误信息
响应体
Error response body: { "error": { "code": 401, "message": "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential.", "errors": [ { "message": "Invalid Credentials", "domain": "global", "reason": "authError", "location": "Authorization", "locationType": "header" } ], "status": "UNAUTHENTICATED" } }
控制台输出
Error fetching tasks: Error: Failed to fetch tasks: 401 Unauthorized fetchTasks Tasks.js:55 Tasks Tasks.js:11
已检查配置
- 已在Google Cloud启用Google Tasks API
- JavaScript源与重定向URL配置正确
- .env文件中Client ID无误
- 已添加权限范围和测试用户
此前使用MongoDB后端时应用可正常运行,现希望移除后端,解决该错误。
相关代码
认证服务代码
const loadGoogleScript = () => { return new Promise((resolve) => { if (typeof window.google !== 'undefined') { resolve(); } else { const script = document.createElement('script'); script.src = 'https://accounts.google.com/gsi/client'; script.async = true; script.defer = true; script.onload = resolve; document.body.appendChild(script); } }); }; export const initializeGoogleAuth = async () => { await loadGoogleScript(); console.log("Using Client ID:", process.env.REACT_APP_GOOGLE_CLIENT_ID); window.google.accounts.id.initialize({ client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID, callback: handleCredentialResponse, scope: 'email profile https://www.googleapis.com/auth/tasks' }); }; export const handleCredentialResponse = (response) => { if (response.error) { console.error("Error during Google Sign-In:", response.error); return; } console.log("Encoded JWT ID token: " + response.credential); const decodedToken = JSON.parse(atob(response.credential.split('.')[1])); console.log("Decoded token:", decodedToken); localStorage.setItem('token', response.credential); localStorage.setItem('user', JSON.stringify({ name: decodedToken.name, email: decodedToken.email })); window.location.href = '/welcome'; }; export const renderGoogleSignInButton = () => { if (typeof window.google !== 'undefined') { window.google.accounts.id.renderButton( document.getElementById("googleSignInButton"), { theme: "outline", size: "large", type: "standard", shape: "rectangular", text: "signin_with", logo_alignment: "left", } ); window.google.accounts.id.prompt(); } else { console.error('Google Sign-In script not loaded'); } }; export const isAuthenticated = () => { return localStorage.getItem('user') !== null; }; export const logout = () => { localStorage.removeItem('user'); localStorage.removeItem('token'); window.location.href = '/'; }; export const getAccessToken = () => { return localStorage.getItem('token'); };
Tasks组件代码
import React, { useState, useEffect } from 'react'; import { getAccessToken } from '../services/auth'; const Tasks = () => { const [tasks, setTasks] = useState([]); const [newTask, setNewTask] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [error, setError] = useState(null); useEffect(() => { fetchTasks(); }, []); const logRequest = (url, options) => { console.log('Outgoing request:', { url, method: options.method || 'GET', headers: options.headers, body: options.body }); }; const logResponse = async (response) => { const clone = response.clone(); const data = await clone.text(); console.log('Response received:', { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), data: data }); }; const fetchTasks = async () => { try { const accessToken = getAccessToken(); if (!accessToken) { throw new Error('No access token available'); } console.log('Access Token:', accessToken); const response = await fetch('https://tasks.googleapis.com/tasks/v1/users/@me/lists', { headers: { 'Authorization': `Bearer ${accessToken}` } }); console.log('Response status:', response.status); console.log('Response headers:', JSON.stringify([...response.headers])); if (!response.ok) { const errorBody = await response.text(); console.error('Error response body:', errorBody); throw new Error(`Failed to fetch tasks: ${response.status} ${response.statusText}`); } const data = await response.json(); console.log('Tasks response:', data); setTasks(data.items || []); } catch (error) { console.error('Error fetching tasks:', error); console.error('Error stack:', error.stack); setError('Failed to fetch tasks. Please try again.'); } }; const addTask = async () => { try { const accessToken = getAccessToken(); if (!accessToken) { throw new Error('No access token available'); } const response = await fetch('https://tasks.googleapis.com/tasks/v1/lists/@default/tasks', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTask }) }); if (!response.ok) { throw new Error('Failed to add task'); } const data = await response.json(); setTasks([...tasks, data]); setNewTask(''); } catch (error) { console.error('Error adding task:', error); setError('Failed to add task. Please try again.'); } }; const deleteTask = async (taskId) => { try { const accessToken = getAccessToken(); if (!accessToken) { throw new Error('No access token available'); } const response = await fetch(`https://tasks.googleapis.com/tasks/v1/lists/@default/tasks/${taskId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${accessToken}` } }); if (!response.ok) { throw new Error('Failed to delete task'); } setTasks(tasks.filter(task => task.id !== taskId)); } catch (error) { console.error('Error deleting task:', error); setError('Failed to delete task. Please try again.'); } }; const editTask = async (taskId, newTitle) => { try { const accessToken = getAccessToken(); if (!accessToken) { throw new Error('No access token available'); } const response = await fetch(`https://tasks.googleapis.com/tasks/v1/lists/@default/tasks/${taskId}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle }) }); if (!response.ok) { throw new Error('Failed to edit task'); } const data = await response.json(); setTasks(tasks.map(task => task.id === taskId ? data : task)); } catch (error) { console.error('Error editing task:', error); setError('Failed to edit task. Please try again.'); } }; const filteredTasks = tasks.filter(task => task.title.toLowerCase().includes(searchTerm.toLowerCase()) ); return ( <div> <h2>My Tasks</h2> {error && <p style={{ color: 'red' }}>{error}</p>} <input type="text" placeholder="Search tasks" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> <div> <input type="text" placeholder="Add new task" value={newTask} onChange={(e) => setNewTask(e.target.value)} /> <button onClick={addTask}>Add Task</button> </div> {filteredTasks.length === 0 ? ( <p>No tasks found.</p> ) : ( <ul> {filteredTasks.map(task => ( <li key={task.id}> {task.title} <button onClick={() => deleteTask(task.id)}>Delete</button> <button onClick={() => editTask(task.id, prompt('Enter new title', task.title))}>Edit</button> </li> ))} </ul> )} </div> ); }; export default Tasks;
解决方案
核心问题
你当前使用的是Google ID Token(JWT)而非OAuth 2.0 Access Token调用Tasks API。ID Token仅用于验证用户身份,不具备访问API的权限,必须使用Access Token才能调用Google Tasks API。此前后端可行是因为后端使用授权码模式交换得到了有效的Access Token,而无后端场景需要使用隐式授权模式获取Access Token。
具体修复步骤
切换到OAuth 2.0隐式授权流程
修改认证服务代码,使用google.accounts.oauth2(OAuth 2.0客户端)替代google.accounts.id(ID Token流程),因为无后端场景无法处理授权码的后端交换逻辑。更新认证服务代码
const loadGoogleScript = () => { return new Promise((resolve) => { if (typeof window.google !== 'undefined') { resolve(); } else { const script = document.createElement('script'); script.src = 'https://accounts.google.com/gsi/client'; script.async = true; script.defer = true; script.onload = resolve; document.body.appendChild(script); } }); }; export const initializeGoogleAuth = async () => { await loadGoogleScript(); console.log("Using Client ID:", process.env.REACT_APP_GOOGLE_CLIENT_ID); // 初始化OAuth 2.0 Token客户端 window.google.accounts.oauth2.initTokenClient({ client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID, scope: 'email profile https://www.googleapis.com/auth/tasks', callback: handleTokenResponse, }); }; export const handleTokenResponse = (response) => { if (response.error !== undefined) { console.error("OAuth Error:", response.error); return; } // 存储有效的Access Token localStorage.setItem('access_token', response.access_token); // 解析ID Token获取用户信息(可选,用于展示用户资料) const decodedToken = JSON.parse(atob(response.id_token.split('.')[1])); localStorage.setItem('user', JSON.stringify({ name: decodedToken.name, email: decodedToken.email })); window.location.href = '/welcome'; }; export const renderGoogleSignInButton = () => { if (typeof window.google !== 'undefined') { // 绑定点击事件触发OAuth授权 const button = document.getElementById("googleSignInButton"); button.addEventListener('click', () => { window.google.accounts.oauth2.getToken({ prompt: 'select_account' // 强制用户选择账号,可选值还有''(静默授权) }); }); } else { console.error('Google Sign-In script not loaded'); } }; export const isAuthenticated = () => { return localStorage.getItem('access_token') !== null; }; export const logout = () => { const token = localStorage.getItem('access_token'); localStorage.removeItem('user'); localStorage.removeItem('access_token'); // 可选:撤销Token,防止后续滥用 if (token && typeof window.google !== 'undefined') { window.google.accounts.oauth2.revoke(token, () => console.log('Token revoked')); } window.location.href = '/'; }; export const getAccessToken = () => { return localStorage.getItem('access_token'); };添加Token过期处理
在调用API前检查Token是否过期,若过期则触发静默授权获取新Token:// 在fetchTasks等API调用函数中添加 const checkAndRefreshToken = async () => { const token = getAccessToken(); if (!token) return null; // 解析Token获取过期时间 const decoded = JSON.parse(atob(token.split('.')[1])); const isExpired = decoded.exp * 1000 < Date.now(); if (isExpired) { // 静默获取新Token return new Promise((resolve) => { window.google.accounts.oauth2.getToken({ prompt: '' }, (response) => { if (response.access_token) { localStorage.setItem('access_token', response.access_token); resolve(response.access_token); } else { resolve(null); } }); }); } return token; }; // 在fetchTasks中使用 const fetchTasks = async () => { try { const accessToken = await checkAndRefreshToken(); if (!accessToken) { throw new Error('No valid access token available'); } // 后续API调用逻辑不变 } catch (error) { // 错误处理 } };确认Google Cloud配置
- 确保OAuth同意屏幕中已添加
https://www.googleapis.com/auth/tasks到授权范围 - 确认JavaScript源地址完全匹配(例如
http://localhost:3000,包括协议和端口)
- 确保OAuth同意屏幕中已添加
内容的提问来源于stack exchange,提问作者Sln
相关产品推荐
相关产品推荐

