Next.js13集成next-auth v4时遭遇OAuthAccountNotLinked错误求助
问题描述
我在Next.js 13中使用next-auth v4实现认证功能,目标是将Google认证提供商返回的用户邮箱、姓名、头像及ID存储到MongoDB数据库中,但始终遇到OAuthAccountNotLinked错误,浏览器地址栏会跳转到:http://localhost:3000/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Flogin&error=OAuthAccountNotLinked
相关文件代码
app/auth/[...nextauth]/route.js 文件
import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import User from "../../models/User"; import bcrypt from "bcryptjs"; import dbConnect from "../../lib/dbConnect"; import GoogleProvider from "next-auth/providers/google"; import { MongoDBAdapter } from "@next-auth/mongodb-adapter"; import clientPromise from "../../lib/mongodb"; const handler = NextAuth({ adapter: MongoDBAdapter(clientPromise), session: { strategy: "database", }, callbacks: { async signIn({ user, account, profile }) { console.log("user: ", user); console.log("account: ", account); console.log(profile); return true; }, }, providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), CredentialsProvider({ name: "credentials", async authorize(credentials, req) { await dbConnect(); const { email, password } = credentials; if (!email || !password) { throw new Error("Email and password required"); } const user = await User.findOne({ email }); if (!user) { throw new Error("Invalid Email or password"); } const isPasswordMatched = await bcrypt.compare(password, user.password); if (!isPasswordMatched) { throw new Error("Invalid Email or password"); } return user; }, }), ], pages: { signIn: "/auth/login", }, secret: process.env.NEXT_AUTH_SECRET, }); export { handler as GET, handler as POST };
dbConnect.js
import mongoose from "mongoose"; const MONGODB_URI = process.env.SERVER === "dev" ? "mongodb://127.0.0.1:27017/mosque-around-me" : process.env.MONGODB_URI; console.log(MONGODB_URI); if (!MONGODB_URI) { throw new Error( "Please define the MONGODB_URI environment variable inside .env.local" ); } /** * Global is used here to maintain a cached connection across hot reloads * in development. This prevents connections growing exponentially * during API Route usage. */ let cached = global.mongoose; if (!cached) { cached = global.mongoose = { conn: null, promise: null }; } async function dbConnect() { if (cached.conn) { return cached.conn; } if (!cached.promise) { const opts = { bufferCommands: true, }; cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => { return mongoose; }); } try { cached.conn = await cached.promise; } catch (e) { cached.promise = null; throw e; } return cached.conn; } export default dbConnect;
mongodb.js(MongoClient promise)
// This approach is taken from https://github.com/vercel/next.js/tree/canary/examples/with-mongodb import { MongoClient } from "mongodb"; if (!process.env.MONGODB_URI) { throw new Error('Invalid/Missing environment variable: "MONGODB_URI"'); } const uri = process.env.MONGODB_URI; const options = {}; let client; let clientPromise; if (process.env.NODE_ENV === "development") { // In development mode, use a global variable so that the value // is preserved across module reloads caused by HMR (Hot Module Replacement). if (!global._mongoClientPromise) { client = new MongoClient(uri, options); global._mongoClientPromise = client.connect(); } clientPromise = global._mongoClientPromise; } else { // In production mode, it's best to not use a global variable. client = new MongoClient(uri, options); clientPromise = client.connect(); } // Export a module-scoped MongoClient promise. By doing this in a // separate module, the client can be shared across functions. export default clientPromise;
user.js(User模型 schema)
import mongoose from "mongoose"; import bcrypt from "bcryptjs"; // const jwt = require("jsonwebtoken"); const UserSchema = new mongoose.Schema( { name: { type: String, minlength: 2, maxlength: 50, required: [true, "Please provide first name"], trim: true, }, phoneNumber: { type: String, }, email: { type: String, match: [ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, "Please provide a valid email", ], required: [true, "Please provide an email"], unique: [true, "Someone is alreay using this email"], }, authProvider: { type: String, required: [true, "Auth provider required"], }, password: { type: String, minlength: 8, required: [true, "Please provide password"], }, location: { type: String, minlength: 3, trim: true, default: "my town", }, lga: { type: String, minlength: 3, trim: true, default: "my town", }, state: { type: String, minlength: 3, trim: true, default: "my town", }, country: { type: String, minlength: 3, trim: true, default: "my town", }, verified: { type: Boolean, default: false, }, active: { type: Boolean, default: true, }, verificationCode: { type: String, length: 4, }, image: String, role: { type: String, required: [true, "Please provide user role"], default: "user", enum: { values: ["staff", "admin", "user"], message: "Please select valid role", }, }, }, { timestamps: true } ); // hash the password before saving it UserSchema.pre("save", async function (next) { // exit when login with google and facebook if (!this.password) return; // exit the function when other fields are updated if (!this.isModified("password")) return; const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); next(); }); UserSchema.methods.comparePassword = async function (userPassword) { const isMatch = await bcrypt.compare(userPassword, this.password); return isMatch; }; // export new User model if not created already export default mongoose.models.User || mongoose.model("User", UserSchema);
问题分析与解决方案
核心问题原因
- 双数据存储冲突:同时使用
MongoDBAdapter(原生MongoDB驱动)和自定义MongooseUser模型,两者的用户数据结构不兼容。next-auth适配器自动创建的用户没有authProvider和password必填字段,导致OAuth用户创建失败。 - 账号关联逻辑缺失:当邮箱已通过Credentials方式注册时,next-auth无法将其与新的Google OAuth账号关联,触发
OAuthAccountNotLinked错误。
解决方案一:统一使用MongoDB Adapter,适配自定义模型
1. 修改User Schema,移除强约束
const UserSchema = new mongoose.Schema( { name: { type: String, minlength: 2, maxlength: 50, required: [true, "Please provide first name"], trim: true, }, phoneNumber: { type: String, }, email: { type: String, match: [ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, "Please provide a valid email", ], required: [true, "Please provide an email"], unique: [true, "Someone is alreay using this email"], }, authProvider: { type: String, default: "credentials", // 设置默认值,移除必填 }, password: { type: String, minlength: 8, required: false, // OAuth用户不需要密码,移除必填 }, // 其余字段保持不变 }, { timestamps: true } );
2. 完善signIn回调,处理用户创建与关联
callbacks: { async signIn({ user, account, profile }) { await dbConnect(); const existingUser = await User.findOne({ email: user.email }); if (!existingUser) { // 创建OAuth用户 await User.create({ name: user.name, email: user.email, image: user.image, authProvider: account.provider, }); return true; } // 关联已有用户与当前OAuth提供商 if (!existingUser.authProvider.includes(account.provider)) { existingUser.authProvider = `${existingUser.authProvider},${account.provider}`; await existingUser.save(); } return true; }, },
3. 调整CredentialsProvider的查询逻辑
// 在authorize方法中,只查询credentials类型的用户 const user = await User.findOne({ email, authProvider: "credentials" });
解决方案二:放弃MongoDB Adapter,完全自定义认证流程
1. 移除Adapter配置,改用JWT会话策略
const handler = NextAuth({ session: { strategy: "jwt", }, callbacks: { async signIn({ user, account, profile }) { await dbConnect(); const existingUser = await User.findOne({ email: user.email }); if (!existingUser) { // 创建OAuth用户 await User.create({ name: user.name, email: user.email, image: user.image, authProvider: account.provider, password: "", }); return true; } // 检查是否已关联当前提供商 if (existingUser.authProvider === account.provider) { return true; } // 未关联则抛出错误,可自定义提示 throw new Error("该邮箱已通过其他方式注册,请使用对应方式登录或关联账号"); }, async jwt({ token, user }) { // 将用户信息存入JWT if (user) { token.id = user._id; token.image = user.image; token.authProvider = user.authProvider; } return token; }, async session({ session, token }) { // 将JWT信息同步到会话 session.user.id = token.id; session.user.image = token.image; session.user.authProvider = token.authProvider; return session; }, }, // 其余配置保持不变 });
额外注意事项
- 清理MongoDB中重复用户数据:如果同一邮箱存在于next-auth自动创建的
users集合和自定义users集合,需手动合并或删除重复项。 - 验证Google OAuth回调地址:在Google Cloud控制台中,回调地址必须设置为
http://localhost:3000/api/auth/callback/google。 - 确认环境变量配置:
NEXT_AUTH_SECRET、GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET和MONGODB_URI需正确配置。
内容的提问来源于stack exchange,提问作者Bello Shehu
相关产品推荐
相关产品推荐

