diff --git a/app/(auth)/link-account/LinkAccountClient.tsx b/app/(auth)/link-account/LinkAccountClient.tsx
index 7838e62d..13555175 100644
--- a/app/(auth)/link-account/LinkAccountClient.tsx
+++ b/app/(auth)/link-account/LinkAccountClient.tsx
@@ -17,13 +17,13 @@ import { signIn } from "next-auth/react";
interface LinkAccountClientProps {
provider: string;
email: string;
- linkingToken: string;
+ linkingId: string;
}
export default function LinkAccountClient({
provider,
email,
- linkingToken
+ linkingId
}: LinkAccountClientProps) {
const router = useRouter();
const [password, setPassword] = useState("");
@@ -41,7 +41,7 @@ export default function LinkAccountClient({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- linkingToken,
+ linkingId,
password
})
});
diff --git a/app/(auth)/link-account/page.tsx b/app/(auth)/link-account/page.tsx
index 4a6054d1..4fb6d759 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 { retrieveLinkingToken, verifyLinkingToken } from "@/src/lib/services/account-linking";
+import { peekLinkingToken, verifyLinkingToken } from "@/src/lib/services/account-linking";
import LinkAccountClient from "./LinkAccountClient";
interface LinkAccountPageProps {
@@ -26,25 +26,27 @@ export default async function LinkAccountPage({ searchParams }: LinkAccountPageP
const linkingId = errorParam.replace("LINKING_REQUIRED:", "");
- // Retrieve the raw JWT from the server-side store (one-time use)
- const rawToken = await retrieveLinkingToken(linkingId);
+ // Peek at the raw JWT to decode display info (provider, email) without consuming it.
+ // The API endpoint will consume (retrieve + delete) the token during password verification.
+ const rawToken = await peekLinkingToken(linkingId);
if (!rawToken) {
redirect("/login?error=Linking token expired or invalid");
}
- // Verify token and decode
+ // Verify token and decode for display purposes only
const tokenPayload = await verifyLinkingToken(rawToken);
if (!tokenPayload) {
redirect("/login?error=Linking token expired or invalid");
}
+ // Pass only the opaque linkingId to the client — the raw JWT never leaves the server
return (
);
}
diff --git a/app/api/auth/link-account/route.ts b/app/api/auth/link-account/route.ts
index 28064d46..a8644230 100644
--- a/app/api/auth/link-account/route.ts
+++ b/app/api/auth/link-account/route.ts
@@ -1,22 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
-import { verifyLinkingToken, verifyAndLinkOAuth } from "@/src/lib/services/account-linking";
+import { retrieveLinkingToken, verifyLinkingToken, verifyAndLinkOAuth } from "@/src/lib/services/account-linking";
import { createAuditEvent } from "@/src/lib/models/audit";
-import { registerFailedAttempt } from "@/src/lib/rate-limit";
+import { isRateLimited, registerFailedAttempt, resetAttempts } from "@/src/lib/rate-limit";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
- const { linkingToken, password } = body;
+ const { linkingId, password } = body;
- if (!linkingToken || !password) {
+ if (!linkingId || !password) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
- // Verify linking token
- const tokenPayload = await verifyLinkingToken(linkingToken);
+ // Retrieve and consume the linking token server-side — the raw JWT never reaches the browser
+ const rawToken = await retrieveLinkingToken(linkingId);
+ if (!rawToken) {
+ return NextResponse.json(
+ { error: "Authentication failed" },
+ { status: 401 }
+ );
+ }
+
+ const tokenPayload = await verifyLinkingToken(rawToken);
if (!tokenPayload) {
return NextResponse.json(
{ error: "Authentication failed" },
@@ -24,11 +32,10 @@ export async function POST(request: NextRequest) {
);
}
- // Rate limiting: prevent brute force password attacks during OAuth linking
+ // Rate limiting: check before attempting password verification
const rateLimitKey = `oauth-link-verify:${tokenPayload.userId}`;
- const rateLimitResult = registerFailedAttempt(rateLimitKey);
- if (rateLimitResult.blocked) {
- // Audit log for blocked attempt
+ const rateLimitCheck = isRateLimited(rateLimitKey);
+ if (rateLimitCheck.blocked) {
await createAuditEvent({
userId: tokenPayload.userId,
action: "oauth_link_rate_limited",
@@ -53,7 +60,9 @@ export async function POST(request: NextRequest) {
);
if (!success) {
- // Audit log for failed password verification
+ // Count this failure against the rate limit
+ registerFailedAttempt(rateLimitKey);
+
await createAuditEvent({
userId: tokenPayload.userId,
action: "oauth_link_password_failed",
@@ -69,7 +78,9 @@ export async function POST(request: NextRequest) {
);
}
- // Audit log
+ // Success — clear rate limit for this user
+ resetAttempts(rateLimitKey);
+
await createAuditEvent({
userId: tokenPayload.userId,
action: "account_linked",
diff --git a/app/api/instances/sync/route.ts b/app/api/instances/sync/route.ts
index 7861d59b..3eb98e2c 100644
--- a/app/api/instances/sync/route.ts
+++ b/app/api/instances/sync/route.ts
@@ -16,13 +16,12 @@ const SYNC_RATE_LIMITS = new Map
* Timing-safe token comparison to prevent timing attacks
*/
function secureTokenCompare(a: string, b: string): boolean {
- if (a.length !== b.length) {
- // Compare against dummy to maintain constant time
- const dummy = Buffer.alloc(a.length, 0);
- timingSafeEqual(Buffer.from(a), dummy);
- return false;
- }
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
+ // Always compare buffers of the expected length (b) to avoid leaking
+ // the expected token length via early-return timing when a.length !== b.length
+ const bufA = Buffer.from(a.padEnd(b.length, "\0").slice(0, b.length));
+ const bufB = Buffer.from(b);
+ const equal = timingSafeEqual(bufA, bufB);
+ return equal && a.length === b.length;
}
function getClientIp(request: NextRequest): string {
diff --git a/drizzle/0007_linking_tokens.sql b/drizzle/0007_linking_tokens.sql
index 52556696..be1eb043 100644
--- a/drizzle/0007_linking_tokens.sql
+++ b/drizzle/0007_linking_tokens.sql
@@ -1,5 +1,7 @@
CREATE TABLE `linking_tokens` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
- `created_at` text NOT NULL
+ `created_at` text NOT NULL,
+ `expires_at` text NOT NULL
);
+CREATE INDEX `linking_tokens_expires_at_idx` ON `linking_tokens` (`expires_at`);
diff --git a/next.config.mjs b/next.config.mjs
index 16190507..e238e865 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -16,8 +16,10 @@ const nextConfig = {
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
- // X-Frame-Options omitted: frame-ancestors in CSP supersedes it in all modern browsers
+ // X-Frame-Options kept for legacy browsers that don't support frame-ancestors CSP directive
+ { key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" },
{
key: "Content-Security-Policy",
value: [
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 5a98f78b..edd26687 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -182,5 +182,6 @@ export const auditEvents = sqliteTable("audit_events", {
export const linkingTokens = sqliteTable("linking_tokens", {
id: text("id").primaryKey(),
token: text("token").notNull(),
- createdAt: text("created_at").notNull()
+ createdAt: text("created_at").notNull(),
+ expiresAt: text("expires_at").notNull()
});
diff --git a/src/lib/services/account-linking.ts b/src/lib/services/account-linking.ts
index 1dfd6d09..039b0e47 100644
--- a/src/lib/services/account-linking.ts
+++ b/src/lib/services/account-linking.ts
@@ -5,7 +5,7 @@ import { config } from "../config";
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user";
import db from "../db";
import { users, linkingTokens } from "../db/schema";
-import { eq } from "drizzle-orm";
+import { eq, lt } from "drizzle-orm";
import { nowIso } from "../db";
const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds
@@ -124,25 +124,53 @@ export async function verifyLinkingToken(token: string): Promise {
const id = randomBytes(32).toString("hex");
+ const now = nowIso();
+ const expiresAt = new Date(Date.now() + LINKING_TOKEN_EXPIRY * 1000).toISOString();
+
+ // Purge expired tokens opportunistically
+ await db.delete(linkingTokens).where(lt(linkingTokens.expiresAt, now));
+
await db.insert(linkingTokens).values({
id,
token,
- createdAt: nowIso()
+ createdAt: now,
+ expiresAt
});
return id;
}
+/**
+ * Peek at a linking token by its opaque ID without consuming (deleting) it.
+ * Used by the link-account page to decode display info (provider, email) while
+ * keeping the token available for the subsequent API call.
+ * Returns null if the ID is not found or the token is expired.
+ */
+export async function peekLinkingToken(id: string): Promise {
+ const now = nowIso();
+ const rows = await db.select().from(linkingTokens)
+ .where(eq(linkingTokens.id, id))
+ .limit(1);
+ if (rows.length === 0 || rows[0].expiresAt < now) {
+ return null;
+ }
+ return rows[0].token;
+}
+
/**
* Retrieve and delete a linking token by its opaque ID (one-time use).
- * Returns null if the ID is not found.
+ * Returns null if the ID is not found or the token is expired.
*/
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) {
+ const now = nowIso();
+ const rows = await db.select().from(linkingTokens)
+ .where(eq(linkingTokens.id, id))
+ .limit(1);
+ if (rows.length === 0 || rows[0].expiresAt < now) {
return null;
}
const { token } = rows[0];
@@ -199,7 +227,7 @@ export async function autoLinkOAuth(
// Don't auto-link if user has a password (unless explicitly called for authenticated linking)
// This check is bypassed when called from the authenticated linking flow
- if (user.password_hash && !process.env.OAUTH_ALLOW_AUTO_LINKING) {
+ if (user.password_hash && !config.oauth.allowAutoLinking) {
return false;
}