fix: store OAuth linking token server-side, remove JWT from URL and audit log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-02-25 09:31:27 +01:00
parent 5d219095b3
commit 9a189ea342
6 changed files with 84 additions and 10 deletions

View File

@@ -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
<LinkAccountClient
provider={tokenPayload.provider}
email={tokenPayload.email}
linkingToken={linkingToken}
linkingToken={rawToken}
/>
);
}

View File

@@ -0,0 +1,5 @@
CREATE TABLE `linking_tokens` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`created_at` text NOT NULL
);

View File

@@ -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
}
]
}
}

View File

@@ -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

View File

@@ -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()
});

View File

@@ -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<LinkingTokenPay
}
}
/**
* Store a linking JWT in the DB and return an opaque 64-char hex ID
*/
export async function storeLinkingToken(token: string): Promise<string> {
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<string | null> {
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
*/