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

Next.js+TypeScript中Gmail API OAuth2刷新令牌过期的解决方法

问题描述

我使用Next.js+TypeScript结合googleapis库开发应用,需通过Gmail API搭配Nodemailer实现邮件发送,依赖OAuth2认证。在Google Cloud Console配置OAuth2后获取refresh token并存储于本地.env文件(仅测试用,后续将存入数据库),初始邮件发送功能正常,但refresh token过期后出现invalid_grant(令牌已过期或被撤销)错误,现需了解如何获取新的refresh token,以及当前代码存在的问题。

错误详情

{
  "config": {
    "retry": true,
    "retryConfig": {
      "httpMethodsToRetry": [Array],
      "currentRetryAttempt": 0,
      "retry": 3,
      "noResponseRetries": 2,
      "retryDelayMultiplier": 2,
      "timeOfFirstRequest": 1722252617571,
      "totalTimeout": 9007199254740991,
      "maxRetryDelay": 9007199254740991,
      "statusCodesToRetry": [Array]
    },
    "method": "POST",
    "url": "https://oauth2.googleapis.com/token",
    "data": "<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.",
    "headers": {
      "Content-Type": "application/x-www-form-urlencoded",
      "User-Agent": "google-api-nodejs-client/9.11.0",
      "x-goog-api-client": "gl-node/20.9.0"
    },
    "paramsSerializer": [Function: paramsSerializer],
    "body": "<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.",
    "validateStatus": [Function: validateStatus],
    "responseType": "unknown",
    "errorRedactor": [Function: defaultErrorRedactor]
  },
  "response": {
    "config": {
      "retry": true,
      "retryConfig": [Object],
      "method": "POST",
      "url": "https://oauth2.googleapis.com/token",
      "data": "<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.",
      "headers": [Object],
      "paramsSerializer": [Function: paramsSerializer],
      "body": "<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.",
      "validateStatus": [Function: validateStatus],
      "responseType": "unknown",
      "errorRedactor": [Function: defaultErrorRedactor]
    },
    "data": {
      "error": "invalid_grant",
      "error_description": "Token has been expired or revoked."
    },
    "headers": {
      "alt-svc": "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000",
      "cache-control": "no-cache, no-store, max-age=0, must-revalidate",
      "content-encoding": "gzip",
      "content-type": "application/json; charset=utf-8",
      "date": "Mon, 29 Jul 2024 11:30:17 GMT",
      "expires": "Mon, 01 Jan 1990 00:00:00 GMT",
      "pragma": "no-cache",
      "server": "scaffolding on HTTPServer2",
      "transfer-encoding": "chunked",
      "vary": "Origin, X-Origin, Referer",
      "x-content-type-options": "nosniff",
      "x-frame-options": "SAMEORIGIN",
      "x-xss-protection": "0"
    },
    "status": 400,
    "statusText": "Bad Request",
    "request": { "responseURL": "https://oauth2.googleapis.com/token" }
  },
  "error": undefined,
  "status": 400,
  "[Symbol(gaxios-gaxios-error)]": "6.7.0"
}
POST /api/contact 500 in 1132ms

当前代码

import type { NextApiRequest, NextApiResponse } from 'next';
import dotenv from 'dotenv';
import nodemailer from 'nodemailer';
import {google} from 'googleapis';
import cors from 'cors';
import { createRouter} from 'next-connect';

const corsOptions = {
    origin: 'http://localhost:3000',
    methods: ['POST'],
  };

const OAuth2 = google.auth.OAuth2;
dotenv.config();

const jwtSecret = process.env.JWT_SECRET;
const gmailAcc = process.env.EMAIL_USER;
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
const redirectUri = process.env.REDIRECT_URI;
const refreshToken = process.env.REFRESH_TOKEN;
const captchaSecret = process.env.RECAPTCHA_SECRET;

if (!jwtSecret || !gmailAcc) {
    throw new Error('Environment variables ADMIN_USERNAME, ADMIN_PASSWORD, and JWT_SECRET must be set');
  };

  const oauth2Client = new OAuth2(
    clientId,
    clientSecret,
    redirectUri
  );

  oauth2Client.setCredentials({
    refresh_token: refreshToken
  });

  async function getAccessToken() {
    const { token } = await oauth2Client.getAccessToken();
    return token;
  }
  

async function createTransporter() {
    const accessToken = await getAccessToken();
    //console.log(accessToken)
    return nodemailer.createTransport({
      service: 'gmail',
      auth: {
        type: 'OAuth2',
        user: gmailAcc,
        clientId: clientId,
        clientSecret: clientSecret,
        refreshToken: refreshToken,
        accessToken: accessToken,
      },
    } as nodemailer.TransportOptions);
  };

const sendMailRouter = createRouter<NextApiRequest, NextApiResponse>();

sendMailRouter.use(cors(corsOptions));

sendMailRouter.post(async (req: NextApiRequest, res: NextApiResponse)=>{
    const {userName, email, message, captcha} = req.body;
    const verifyUrl = `https://www.google.com/recaptcha/api/siteverify?secret=${captchaSecret}&response=${captcha}`;
    const recaptchaResponse = await fetch(verifyUrl, { method: 'POST' });
    const recaptchaResult = await recaptchaResponse.json();

    if(
        !userName 
        || 
        !email 
        || 
        !message
       )
    {
        return res.status(500).json({ error: 'All input fields are required!' });
    }
    if (
        !recaptchaResponse.ok 
        || 
        !recaptchaResult.success 
        || 
        recaptchaResult.score <= 0.4        
    ){
        return res.status(500).json({ error: 'Anti-spam protection!' });
    }
    const mailOptions = {
        to: gmailAcc,
        from: email,
        subject: `Съобщение от ${userName}, изпратено от www.yavorm.com`,
        text: `${message}`,
      };
    
    try {
        const transporter = await createTransporter();
        await transporter.sendMail(mailOptions);
        return res.status(200).json({ message: 'Message sent! Thank You!' });
    } catch (error:any) {
        console.error('Error sending email:', error);
        return res.status(500).json({ error: 'Error sending email!' });
    }
})

export default sendMailRouter.handler({
    onError: (err:any, req, res) => {
      console.error(err.stack);
    },
  });
解决方案

一、获取新的Refresh Token

  • 登录Google Cloud Console,进入对应项目的OAuth 2.0客户端ID页面
  • 删除旧的客户端ID,重新创建新的客户端ID(确保重定向URI与之前一致)
  • 重新执行OAuth授权流程:构造授权URL,访问该URL登录账号并完成授权,用获取到的授权码兑换新的refresh token
  • 注意:个人测试账号需确保未开启两步验证,或使用应用专用密码;Google的refresh token若长时间未使用会自动过期,生产环境建议搭配服务账号或定期刷新机制

二、当前代码的问题及修复

存在的问题

  1. 未处理refresh token过期逻辑:直接从环境变量读取refresh token,没有应对token过期后的刷新或重新获取逻辑
  2. 全局OAuth2实例未更新凭据:全局创建的oauth2Client实例一旦refresh token过期,后续所有请求都会失败,无法自动更新凭据
  3. Nodemailer配置冗余:手动获取access token并传入,实际上Nodemailer的OAuth2认证可自动处理access token刷新,无需手动调用getAccessToken

修复后的代码

import type { NextApiRequest, NextApiResponse } from 'next';
import dotenv from 'dotenv';
import nodemailer from 'nodemailer';
import { google } from 'googleapis';
import cors from 'cors';
import { createRouter } from 'next-connect';

dotenv.config();

const corsOptions = {
  origin: 'http://localhost:3000',
  methods: ['POST'],
};

const OAuth2 = google.auth.OAuth2;

const gmailAcc = process.env.EMAIL_USER;
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
const redirectUri = process.env.REDIRECT_URI;
let refreshToken = process.env.REFRESH_TOKEN;
const captchaSecret = process.env.RECAPTCHA_SECRET;

// 验证OAuth2相关环境变量
if (!gmailAcc || !clientId || !clientSecret || !redirectUri || !refreshToken) {
  throw new Error('OAuth2相关环境变量必须配置齐全');
}

const oauth2Client = new OAuth2(clientId, clientSecret, redirectUri);

// 创建可自动刷新token的邮件传输器
async function createTransporter() {
  return nodemailer.createTransport({
    service: 'gmail',
    auth: {
      type: 'OAuth2',
      user: gmailAcc,
      clientId: clientId,
      clientSecret: clientSecret,
      refreshToken: refreshToken,
      // 让Nodemailer自动处理access token刷新
      accessToken: async () => {
        try {
          const { token } = await oauth2Client.getAccessToken();
          return token;
        } catch (err) {
          console.error('获取access token失败:', err);
          throw new Error('refresh token已过期,请重新获取');
        }
      },
    },
  } as nodemailer.TransportOptions);
};

const sendMailRouter = createRouter<NextApiRequest, NextApiResponse>();

sendMailRouter.use(cors(corsOptions));

sendMailRouter.post(async (req: NextApiRequest, res: NextApiResponse) => {
  const { userName, email, message, captcha } = req.body;

  // 验证必填字段
  if (!userName || !email || !message) {
    return res.status(400).json({ error: '所有输入字段均为必填项!' });
  }

  // 验证reCAPTCHA
  try {
    const recaptchaResponse = await fetch(
      `https://www.google.com/recaptcha/api/siteverify?secret=${captchaSecret}&response=${captcha}`,
      { method: 'POST' }
    );
    const recaptchaResult = await recaptchaResponse.json();

    if (!recaptchaResult.success || recaptchaResult.score <= 0.4) {
      return res.status(403).json({ error: '反垃圾验证未通过!' });
    }
  } catch (err) {
    return res.status(500).json({ error: '反垃圾验证服务异常!' });
  }

  const mailOptions = {
    to: gmailAcc,
    from: email,
    subject: `来自${userName}的消息,发送自www.yavorm.com`,
    text: message,
  };

  try {
    const transporter = await createTransporter();
    await transporter.sendMail(mailOptions);
    return res.status(200).json({ message: '消息发送成功!感谢您的留言!' });
  } catch (error: any) {
    console.error('发送邮件失败:', error);
    // 针对性处理refresh token过期错误
    if (error.message.includes('invalid_grant')) {
      return res.status(500).json({ error: '认证令牌过期,请重新获取refresh token!' });
    }
    return res.status(500).json({ error: '邮件发送失败!' });
  }
});

export default sendMailRouter.handler({
  onError: (err: any, req, res) => {
    console.error(err.stack);
    res.status(500).json({ error: '服务器内部错误!' });
  },
});

修复说明

  1. 让Nodemailer自动管理access token:通过accessToken传入异步函数,Nodemailer会在需要时自动调用获取新的access token,无需手动维护
  2. 添加错误分类处理:捕获invalid_grant错误,返回明确的提示信息,方便排查问题
  3. 优化环境变量验证:确保所有OAuth2相关变量都已配置,避免遗漏导致的启动错误
  4. 规范HTTP状态码:必填字段缺失返回400,反垃圾验证未通过返回403,符合RESTful接口规范

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.20 06:37:33