You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

无后端React应用调用Google Tasks API遇401未授权错误求助

解决Google Tasks API 401 Unauthorized错误(无后端React应用)

问题描述

我正在开发一个仅使用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。

具体修复步骤

  1. 切换到OAuth 2.0隐式授权流程
    修改认证服务代码,使用google.accounts.oauth2(OAuth 2.0客户端)替代google.accounts.id(ID Token流程),因为无后端场景无法处理授权码的后端交换逻辑。

  2. 更新认证服务代码

    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');
    };
    
  3. 添加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) {
            // 错误处理
        }
    };
    
  4. 确认Google Cloud配置

    • 确保OAuth同意屏幕中已添加https://www.googleapis.com/auth/tasks到授权范围
    • 确认JavaScript源地址完全匹配(例如http://localhost:3000,包括协议和端口)

内容的提问来源于stack exchange,提问作者Sln

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 15:58:11