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 */