diff --git a/app/(auth)/link-account/page.tsx b/app/(auth)/link-account/page.tsx
index bae37865..4a6054d1 100644
--- a/app/(auth)/link-account/page.tsx
+++ b/app/(auth)/link-account/page.tsx
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { auth } from "@/src/lib/auth";
-import { verifyLinkingToken } from "@/src/lib/services/account-linking";
+import { retrieveLinkingToken, verifyLinkingToken } from "@/src/lib/services/account-linking";
import LinkAccountClient from "./LinkAccountClient";
interface LinkAccountPageProps {
@@ -17,17 +17,24 @@ export default async function LinkAccountPage({ searchParams }: LinkAccountPageP
redirect("/");
}
- // Get linking token from error parameter (NextAuth redirects with error param)
+ // Get linking ID from error parameter (NextAuth redirects with error param)
const errorParam = searchParams.error || "";
if (!errorParam.startsWith("LINKING_REQUIRED:")) {
redirect("/login?error=Invalid linking request");
}
- const linkingToken = errorParam.replace("LINKING_REQUIRED:", "");
+ const linkingId = errorParam.replace("LINKING_REQUIRED:", "");
+
+ // Retrieve the raw JWT from the server-side store (one-time use)
+ const rawToken = await retrieveLinkingToken(linkingId);
+
+ if (!rawToken) {
+ redirect("/login?error=Linking token expired or invalid");
+ }
// Verify token and decode
- const tokenPayload = await verifyLinkingToken(linkingToken);
+ const tokenPayload = await verifyLinkingToken(rawToken);
if (!tokenPayload) {
redirect("/login?error=Linking token expired or invalid");
@@ -37,7 +44,7 @@ export default async function LinkAccountPage({ searchParams }: LinkAccountPageP
);
}
diff --git a/drizzle/0007_linking_tokens.sql b/drizzle/0007_linking_tokens.sql
new file mode 100644
index 00000000..52556696
--- /dev/null
+++ b/drizzle/0007_linking_tokens.sql
@@ -0,0 +1,5 @@
+CREATE TABLE `linking_tokens` (
+ `id` text PRIMARY KEY NOT NULL,
+ `token` text NOT NULL,
+ `created_at` text NOT NULL
+);
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 3944474c..caa68170 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -36,6 +36,27 @@
"when": 1770395358533,
"tag": "0004_slimy_grim_reaper",
"breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "6",
+ "when": 1770395358534,
+ "tag": "0005_remove_static_response",
+ "breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "6",
+ "when": 1770395358535,
+ "tag": "0006_remove_redirects",
+ "breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "6",
+ "when": 1740441600000,
+ "tag": "0007_linking_tokens",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 5392ceb7..2e7ad078 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -8,7 +8,7 @@ import { config } from "./config";
import { users } from "./db/schema";
import { findUserByProviderSubject, findUserByEmail, createUser, getUserById } from "./models/user";
import { createAuditEvent } from "./models/audit";
-import { decideLinkingStrategy, createLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking";
+import { decideLinkingStrategy, createLinkingToken, storeLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking";
declare module "next-auth" {
interface Session {
@@ -304,8 +304,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
user.email
);
- // Redirect to link-account page with token
- throw new Error(`LINKING_REQUIRED:${linkingToken}`);
+ const linkingId = await storeLinkingToken(linkingToken);
+
+ // Redirect to link-account page with opaque ID (not the JWT)
+ throw new Error(`LINKING_REQUIRED:${linkingId}`);
}
// New OAuth user - create account (defaults to admin role)
@@ -333,6 +335,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return true;
} catch (error) {
+ // LINKING_REQUIRED is expected flow — rethrow so NextAuth can redirect
+ if (error instanceof Error && error.message.startsWith("LINKING_REQUIRED:")) {
+ throw error;
+ }
+
console.error("OAuth sign-in error:", error);
// Audit log for failed OAuth attempts
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index bfebe890..e9400a0e 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -178,3 +178,9 @@ export const auditEvents = sqliteTable("audit_events", {
data: text("data"),
createdAt: text("created_at").notNull()
});
+
+export const linkingTokens = sqliteTable("linking_tokens", {
+ id: text("id").primaryKey(),
+ token: text("token").notNull(),
+ createdAt: text("created_at").notNull()
+});
diff --git a/src/lib/services/account-linking.ts b/src/lib/services/account-linking.ts
index d7115b0b..1dfd6d09 100644
--- a/src/lib/services/account-linking.ts
+++ b/src/lib/services/account-linking.ts
@@ -1,9 +1,10 @@
import bcrypt from "bcryptjs";
+import { randomBytes } from "crypto";
import { SignJWT, jwtVerify } from "jose";
import { config } from "../config";
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user";
import db from "../db";
-import { users } from "../db/schema";
+import { users, linkingTokens } from "../db/schema";
import { eq } from "drizzle-orm";
import { nowIso } from "../db";
@@ -122,6 +123,33 @@ export async function verifyLinkingToken(token: string): Promise {
+ const id = randomBytes(32).toString("hex");
+ await db.insert(linkingTokens).values({
+ id,
+ token,
+ createdAt: nowIso()
+ });
+ return id;
+}
+
+/**
+ * Retrieve and delete a linking token by its opaque ID (one-time use).
+ * Returns null if the ID is not found.
+ */
+export async function retrieveLinkingToken(id: string): Promise {
+ const rows = await db.select().from(linkingTokens).where(eq(linkingTokens.id, id)).limit(1);
+ if (rows.length === 0) {
+ return null;
+ }
+ const { token } = rows[0];
+ await db.delete(linkingTokens).where(eq(linkingTokens.id, id));
+ return token;
+}
+
/**
* Verify password and link OAuth account to existing user
*/