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

Node.js中Google Calendar Passport OAuth认证失败问题

Node.js 对接 Google Calendar 认证失败排查

问题概述

在Node.js应用中对接Google Calendar时遭遇Invalid Credentials认证错误,已反复核对GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET和GOOGLE_REDIRECT_URL配置,授权重定向URI也经过多次校验,但本地及生产环境均出现相同问题。

错误日志

2024-03-06 12:51:09 info: Deserializing user with ID:
2024-03-06 12:51:09 info: User deserialized successfully:
2024-03-06 12:51:09 info: State parameter from Google:
2024-03-06 12:51:09 info: Entered /auth/google/callback route. Processing...
2024-03-06 12:51:09 info: Initiating Passport authentication...
2024-03-06 12:51:11 info: Passport authentication processed.
2024-03-06 12:51:11 error: Passport authentication error:
2024-03-06 12:51:11 error: Invalid Credentials
2024-03-06 12:51:11 error: Error occurred on route /google-calendar/auth/google/callback - Method: GET

相关代码

const express = require("express");
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const router = express.Router();
const User = require("../models/user");
const { google } = require("googleapis");
const Email = require("../models/email");
const {
  getCalendarEvent,
  createGoogleCalendarEvent,
} = require("../calendarEvents");
const logger = require("../logger");
const logInfo = logger.logInfo;
const logError = logger.logError;

// Setup Passport
passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: process.env.GOOGLE_REDIRECT_URL,
    },
    async function (accessToken, refreshToken, profile, done) {
      logInfo("Entered GoogleStrategy callback");

      // Add detailed logging for received tokens and profile
      logInfo("Received Access Token: ", accessToken);
      logInfo("Received Refresh Token: ", refreshToken);
      logInfo("Received Profile: ", profile);

      // Check if accessToken is a non-empty string
      if (typeof accessToken !== "string" || !accessToken) {
        logError("Access token is either missing or not a string.");
        return done(new Error("No access token received"), null);
      }

      // Check if refreshToken is a non-empty string
      if (typeof refreshToken !== "string" || !refreshToken) {
        logError("Refresh token is either missing or not a string.");
        return done(new Error("No refresh token received"), null);
      }

      let user;
      try {
        user = await User.findOneAndUpdate(
          { googleId: profile.id },
          { googleId: profile.id },
          { upsert: true, new: true },
        );
      } catch (err) {
        logError("Error querying the database for user:", err);
        return done(err);
      }

      if (!user) {
        logError("Unexpected error: No user returned after findOneAndUpdate.");
        return done(
          new Error("Unexpected error during user retrieval or creation"),
          null,
        );
      }
      logInfo("User retrieved or created successfully with ID:", user._id);

      // Before saving tokens, refresh if needed
      const validAccessToken = await checkAndRefreshToken(
        user,
        accessToken,
        refreshToken,
      );
      accessToken = validAccessToken || accessToken;

      // Save the tokens to the user
      try {
        await user.setGoogleTokens({ accessToken, refreshToken });
        logInfo(
          "Access and refresh tokens saved successfully for user:",
          user._id,
        );
      } catch (err) {
        logError("Error while saving tokens for user:", err);
      }

      logInfo("Exiting GoogleStrategy callback");
      done(null, user);
    },
  ),
);

async function checkAndRefreshToken(
  user,
  currentAccessToken,
  currentRefreshToken,
) {
  try {
    const currentTime = Date.now() / 1000;
    const EXPIRY_WINDOW = 300; // 5 minutes

    if (
      !user.googleTokens ||
      !user.googleTokens.expiryDate ||
      currentTime > user.googleTokens.expiryDate - EXPIRY_WINDOW
    ) {
      const oauth2Client = new google.auth.OAuth2(
        process.env.GOOGLE_CLIENT_ID,
        process.env.GOOGLE_CLIENT_SECRET,
        process.env.GOOGLE_REDIRECT_URL,
      );

      // Add detailed logging
      logInfo("Refreshing Google access token...");

      oauth2Client.setCredentials({ refresh_token: currentRefreshToken });
      const newAccessToken = await oauth2Client.getAccessToken();

      if (!newAccessToken || !newAccessToken.token) {
        throw new Error("Failed to refresh the access token");
      }

      return newAccessToken.token;
    }
    return currentAccessToken;
  } catch (err) {
    logError("Error in token refresh:", err);
    throw err;
  }
}

passport.serializeUser((user, done) => {
  logInfo("Serializing user with ID:", user.id);
  done(null, user.id);
});

passport.deserializeUser(async (id, done) => {
  logInfo("Deserializing user with ID:", id);
  try {
    const user = await User.findById(id);
    if (user) {
      logInfo("User deserialized successfully:", user._id);
      done(null, user);
    } else {
      logError("Failed to deserialize user with ID:", id);
      done(new Error("Failed to deserialize user"), null);
    }
  } catch (err) {
    logError("Error during deserialization:", err);
    done(err, null);
  }
});

router.get("/", function (req, res) {
  res.render("google-calendar");
});

// The route to redirect the user to the Google consent screen
router.get(
  "/connect",
  passport.authenticate("google", {
    scope: [
      "https://www.googleapis.com/auth/calendar.readonly",
      "https://www.googleapis.com/auth/calendar.events",
    ],
    accessType: "offline",
    prompt: "consent",
  }),
);

// The route where Google redirects the user after the consent screen
router.get("/auth/google/callback", function (req, res, next) {
  // Log the state parameter, which can be helpful to trace back any CSRF issues
  logInfo("State parameter from Google:", req.query.state);

  // Initial Entry Point
  logInfo("Entered /auth/google/callback route. Processing...");

  // Before Passport Authentication
  logInfo("Initiating Passport authentication...");

  passport.authenticate("google", function (err, user, info) {
    // Enhanced logging
    if (info) {
      logInfo("Additional Passport Info:", JSON.stringify(info, null, 2));
    }

    // Log after passport authentication has processed
    logInfo("Passport authentication processed.");

    if (err) {
      // 3. Inside the Authentication Callback - Error Scenario
      logError("Passport authentication error:", {
        errorMessage: err.message,
        errorStack: err.stack,
        additionalInfo: info,
        error: err,
      });
      return next(err);
    }

    if (!user) {
      // User not found scenario
      logWarn("No user data retrieved during Passport authentication.");
      return res.redirect("/login");
    }

    // Successful authentication
    logInfo("User authenticated successfully:", {
      userId: user.id,
      username: user.username,
    });

    req.logIn(user, function (err) {
      if (err) {
        logError(
          "Error during the login phase after Passport authentication:",
          err,
        );
        return next(err);
      }
      return res.redirect("/users/" + user.username);
    });
  })(req, res, next);
});

module.exports = router;

排查与解决方案

1. 验证环境变量加载

在passport.use初始化前添加日志,确认环境变量是否正确加载:

console.log("Loaded Client ID:", process.env.GOOGLE_CLIENT_ID?.slice(0, 10) + "...");
console.log("Loaded Redirect URL:", process.env.GOOGLE_REDIRECT_URL);

若输出为空或不符,检查.env文件格式、部署环境变量配置,确保变量未被遗漏或覆盖。

2. 核对Google Cloud控制台配置

  • 确认Google Calendar API已启用:进入控制台搜索并启用该API。
  • 检查OAuth客户端类型:本地开发用“桌面应用”,生产环境用“Web应用”。
  • 重定向URI完全匹配:需包含协议(http/https)、域名、端口和完整路径,比如本地为http://localhost:3000/google-calendar/auth/google/callback,生产环境对应实际域名。
  • 确认客户端ID/密钥无格式错误:避免复制时带入多余空格或特殊字符。

3. 修复Token刷新逻辑问题

首次认证时,新用户无历史token记录,当前代码会错误进入刷新分支,导致无效请求。修改checkAndRefreshToken函数:

async function checkAndRefreshToken(user, currentAccessToken, currentRefreshToken) {
  try {
    // 新用户直接返回当前token,无需刷新
    if (!user.googleTokens) {
      return currentAccessToken;
    }
    const currentTime = Date.now() / 1000;
    const EXPIRY_WINDOW = 300;

    if (!user.googleTokens.expiryDate || currentTime > user.googleTokens.expiryDate - EXPIRY_WINDOW) {
      const oauth2Client = new google.auth.OAuth2(
        process.env.GOOGLE_CLIENT_ID,
        process.env.GOOGLE_CLIENT_SECRET,
        process.env.GOOGLE_REDIRECT_URL,
      );
      oauth2Client.setCredentials({ refresh_token: currentRefreshToken });
      const newAccessToken = await oauth2Client.getAccessToken();
      if (!newAccessToken?.token) {
        throw new Error("Failed to refresh the access token");
      }
      return newAccessToken.token;
    }
    return currentAccessToken;
  } catch (err) {
    logError("Error in token refresh:", err);
    throw err;
  }
}

同时,首次保存token时需手动添加过期时间(Google access token默认有效期3600秒):

// 在保存token前添加
const expiryDate = Date.now() + 3600 * 1000;
await user.setGoogleTokens({ accessToken, refreshToken, expiryDate });

4. 清除旧授权缓存

  • 登录Google账号,在“安全”->“第三方应用访问权限”中移除当前应用的授权记录。
  • 本地开发时清除浏览器缓存和Cookie,重新发起授权流程。

5. 增强日志排查

在Passport回调中补充打印info参数,获取Google返回的具体错误详情:

passport.authenticate("google", function (err, user, info) {
  if (info) {
    logInfo("Google Auth Detailed Info:", JSON.stringify(info));
  }
  // 原有逻辑
});

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.28 08:49:53