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若长时间未使用会自动过期,生产环境建议搭配服务账号或定期刷新机制
二、当前代码的问题及修复
存在的问题
- 未处理refresh token过期逻辑:直接从环境变量读取refresh token,没有应对token过期后的刷新或重新获取逻辑
- 全局OAuth2实例未更新凭据:全局创建的oauth2Client实例一旦refresh token过期,后续所有请求都会失败,无法自动更新凭据
- 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: '服务器内部错误!' }); }, });
修复说明
- 让Nodemailer自动管理access token:通过
accessToken传入异步函数,Nodemailer会在需要时自动调用获取新的access token,无需手动维护 - 添加错误分类处理:捕获
invalid_grant错误,返回明确的提示信息,方便排查问题 - 优化环境变量验证:确保所有OAuth2相关变量都已配置,避免遗漏导致的启动错误
- 规范HTTP状态码:必填字段缺失返回400,反垃圾验证未通过返回403,符合RESTful接口规范
内容的提问来源于stack exchange,提问作者Christian
相关产品推荐
相关产品推荐

