diff --git a/apps/web/app/api/google/linking/auth-url/route.ts b/apps/web/app/api/google/linking/auth-url/route.ts index c39705123c..7d15bcec07 100644 --- a/apps/web/app/api/google/linking/auth-url/route.ts +++ b/apps/web/app/api/google/linking/auth-url/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/utils/middleware"; import { getLinkingOAuth2Client } from "@/utils/gmail/client"; -import { GOOGLE_LINKING_STATE_COOKIE_NAME } from "@/utils/gmail/constants"; +import { + GOOGLE_LINKING_STATE_COOKIE_NAME, + GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME, +} from "@/utils/gmail/constants"; import { SCOPES } from "@/utils/gmail/scopes"; import { generateOAuthState, @@ -31,6 +34,7 @@ export const GET = withAuth("google/linking/auth-url", async (request) => { const response = NextResponse.json({ url: authUrl }); + response.cookies.delete(GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME); response.cookies.set( GOOGLE_LINKING_STATE_COOKIE_NAME, state, diff --git a/apps/web/app/api/google/linking/callback/route.ts b/apps/web/app/api/google/linking/callback/route.ts index fba1e8fe44..ab93a6b452 100644 --- a/apps/web/app/api/google/linking/callback/route.ts +++ b/apps/web/app/api/google/linking/callback/route.ts @@ -1,18 +1,36 @@ -import { NextResponse } from "next/server"; import { env } from "@/env"; import prisma from "@/utils/prisma"; import { getLinkingOAuth2Client } from "@/utils/gmail/client"; -import { GOOGLE_LINKING_STATE_COOKIE_NAME } from "@/utils/gmail/constants"; +import { + GOOGLE_LINKING_STATE_COOKIE_NAME, + GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME, +} from "@/utils/gmail/constants"; import { withError } from "@/utils/middleware"; import { validateOAuthCallback } from "@/utils/oauth/callback-validation"; import { handleAccountLinking } from "@/utils/oauth/account-linking"; import { mergeAccount } from "@/utils/user/merge-account"; import { handleOAuthCallbackError } from "@/utils/oauth/error-handler"; +import { + checkOAuthCallbackDedupe, + buildOAuthSuccessRedirect, +} from "@/utils/oauth/callback-helpers"; export const GET = withError("google/linking/callback", async (request) => { const logger = request.logger; + const dedupeResponse = checkOAuthCallbackDedupe({ + request, + stateCookieName: GOOGLE_LINKING_STATE_COOKIE_NAME, + resultCookieName: GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME, + baseUrl: request.nextUrl.origin, + }); + + if (dedupeResponse) { + return dedupeResponse; + } + const searchParams = request.nextUrl.searchParams; + const storedState = request.cookies.get( GOOGLE_LINKING_STATE_COOKIE_NAME, )?.value; @@ -30,10 +48,14 @@ export const GET = withError("google/linking/callback", async (request) => { return validation.response; } + const receivedState = searchParams.get("state"); + if (!receivedState) { + throw new Error("Missing state parameter after validation"); + } + const { targetUserId, code } = validation; - const redirectUrl = new URL("/accounts", request.nextUrl.origin); - const response = NextResponse.redirect(redirectUrl); - response.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME); + const state = receivedState; + const baseRedirectUrl = new URL("/accounts", request.nextUrl.origin); const googleAuth = getLinkingOAuth2Client(); @@ -102,6 +124,10 @@ export const GET = withError("google/linking/callback", async (request) => { }); if (linkingResult.type === "redirect") { + linkingResult.response.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME); + linkingResult.response.cookies.delete( + GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME, + ); return linkingResult.response; } @@ -139,9 +165,12 @@ export const GET = withError("google/linking/callback", async (request) => { targetUserId, accountId: newAccount.id, }); - redirectUrl.searchParams.set("success", "account_created_and_linked"); - return NextResponse.redirect(redirectUrl, { - headers: response.headers, + return buildOAuthSuccessRedirect({ + state, + params: { success: "account_created_and_linked" }, + stateCookieName: GOOGLE_LINKING_STATE_COOKIE_NAME, + resultCookieName: GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME, + baseUrl: request.nextUrl.origin, }); } @@ -173,16 +202,19 @@ export const GET = withError("google/linking/callback", async (request) => { mergeType, }); - redirectUrl.searchParams.set("success", successMessage); - return NextResponse.redirect(redirectUrl, { - headers: response.headers, + return buildOAuthSuccessRedirect({ + state, + params: { success: successMessage }, + stateCookieName: GOOGLE_LINKING_STATE_COOKIE_NAME, + resultCookieName: GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME, + baseUrl: request.nextUrl.origin, }); } catch (error) { return handleOAuthCallbackError({ error, - redirectUrl, - response, + redirectUrl: baseRedirectUrl, stateCookieName: GOOGLE_LINKING_STATE_COOKIE_NAME, + resultCookieName: GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME, logger, }); } diff --git a/apps/web/app/api/outlook/linking/auth-url/route.ts b/apps/web/app/api/outlook/linking/auth-url/route.ts index 86e3f8ffdc..acd1302d76 100644 --- a/apps/web/app/api/outlook/linking/auth-url/route.ts +++ b/apps/web/app/api/outlook/linking/auth-url/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/utils/middleware"; import { getLinkingOAuth2Url } from "@/utils/outlook/client"; -import { OUTLOOK_LINKING_STATE_COOKIE_NAME } from "@/utils/outlook/constants"; +import { + OUTLOOK_LINKING_STATE_COOKIE_NAME, + OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME, +} from "@/utils/outlook/constants"; import { generateOAuthState, oauthStateCookieOptions, @@ -24,6 +27,7 @@ export const GET = withAuth("outlook/linking/auth-url", async (request) => { const response = NextResponse.json({ url: authUrl }); + response.cookies.delete(OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME); response.cookies.set( OUTLOOK_LINKING_STATE_COOKIE_NAME, state, diff --git a/apps/web/app/api/outlook/linking/callback/route.ts b/apps/web/app/api/outlook/linking/callback/route.ts index 45c3172a33..caeae4c1bd 100644 --- a/apps/web/app/api/outlook/linking/callback/route.ts +++ b/apps/web/app/api/outlook/linking/callback/route.ts @@ -1,13 +1,19 @@ -import { NextResponse } from "next/server"; import { env } from "@/env"; import prisma from "@/utils/prisma"; -import { OUTLOOK_LINKING_STATE_COOKIE_NAME } from "@/utils/outlook/constants"; +import { + OUTLOOK_LINKING_STATE_COOKIE_NAME, + OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME, +} from "@/utils/outlook/constants"; import { withError } from "@/utils/middleware"; import { captureException, SafeError } from "@/utils/error"; import { validateOAuthCallback } from "@/utils/oauth/callback-validation"; import { handleAccountLinking } from "@/utils/oauth/account-linking"; import { mergeAccount } from "@/utils/user/merge-account"; import { handleOAuthCallbackError } from "@/utils/oauth/error-handler"; +import { + checkOAuthCallbackDedupe, + buildOAuthSuccessRedirect, +} from "@/utils/oauth/callback-helpers"; export const GET = withError("outlook/linking/callback", async (request) => { const logger = request.logger; @@ -15,7 +21,19 @@ export const GET = withError("outlook/linking/callback", async (request) => { if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) throw new SafeError("Microsoft login not enabled"); + const dedupeResponse = checkOAuthCallbackDedupe({ + request, + stateCookieName: OUTLOOK_LINKING_STATE_COOKIE_NAME, + resultCookieName: OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME, + baseUrl: request.nextUrl.origin, + }); + + if (dedupeResponse) { + return dedupeResponse; + } + const searchParams = request.nextUrl.searchParams; + const storedState = request.cookies.get( OUTLOOK_LINKING_STATE_COOKIE_NAME, )?.value; @@ -33,10 +51,14 @@ export const GET = withError("outlook/linking/callback", async (request) => { return validation.response; } + const receivedState = searchParams.get("state"); + if (!receivedState) { + throw new Error("Missing state parameter after validation"); + } + const { targetUserId, code } = validation; - const redirectUrl = new URL("/accounts", request.nextUrl.origin); - const response = NextResponse.redirect(redirectUrl); - response.cookies.delete(OUTLOOK_LINKING_STATE_COOKIE_NAME); + const state = receivedState; + const baseRedirectUrl = new URL("/accounts", request.nextUrl.origin); try { // Exchange code for tokens @@ -136,6 +158,10 @@ export const GET = withError("outlook/linking/callback", async (request) => { }); if (linkingResult.type === "redirect") { + linkingResult.response.cookies.delete(OUTLOOK_LINKING_STATE_COOKIE_NAME); + linkingResult.response.cookies.delete( + OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME, + ); return linkingResult.response; } @@ -210,9 +236,12 @@ export const GET = withError("outlook/linking/callback", async (request) => { targetUserId, accountId: newAccount.id, }); - redirectUrl.searchParams.set("success", "account_created_and_linked"); - return NextResponse.redirect(redirectUrl, { - headers: response.headers, + return buildOAuthSuccessRedirect({ + state, + params: { success: "account_created_and_linked" }, + stateCookieName: OUTLOOK_LINKING_STATE_COOKIE_NAME, + resultCookieName: OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME, + baseUrl: request.nextUrl.origin, }); } @@ -242,16 +271,19 @@ export const GET = withError("outlook/linking/callback", async (request) => { mergeType, }); - redirectUrl.searchParams.set("success", successMessage); - return NextResponse.redirect(redirectUrl, { - headers: response.headers, + return buildOAuthSuccessRedirect({ + state, + params: { success: successMessage }, + stateCookieName: OUTLOOK_LINKING_STATE_COOKIE_NAME, + resultCookieName: OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME, + baseUrl: request.nextUrl.origin, }); } catch (error) { return handleOAuthCallbackError({ error, - redirectUrl, - response, + redirectUrl: baseRedirectUrl, stateCookieName: OUTLOOK_LINKING_STATE_COOKIE_NAME, + resultCookieName: OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME, logger, }); } diff --git a/apps/web/utils/gmail/constants.ts b/apps/web/utils/gmail/constants.ts index debd3795cf..0c083cb935 100644 --- a/apps/web/utils/gmail/constants.ts +++ b/apps/web/utils/gmail/constants.ts @@ -14,3 +14,5 @@ export type LabelVisibility = (typeof labelVisibility)[keyof typeof labelVisibility]; export const GOOGLE_LINKING_STATE_COOKIE_NAME = "google_linking_state"; +export const GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME = + "google_linking_state_result"; diff --git a/apps/web/utils/oauth/callback-helpers.ts b/apps/web/utils/oauth/callback-helpers.ts new file mode 100644 index 0000000000..0c875dbb8f --- /dev/null +++ b/apps/web/utils/oauth/callback-helpers.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { + parseOAuthStateResultCookie, + encodeOAuthStateResultCookie, + oauthStateCookieOptions, +} from "@/utils/oauth/state"; + +interface CheckDedupeParams { + request: NextRequest; + stateCookieName: string; + resultCookieName: string; + baseUrl: string; +} + +/** + * Checks if this OAuth callback has already been processed. + * If so, returns the cached redirect response. + * Otherwise, returns null to continue processing. + */ +export function checkOAuthCallbackDedupe({ + request, + stateCookieName, + resultCookieName, + baseUrl, +}: CheckDedupeParams): NextResponse | null { + const receivedState = request.nextUrl.searchParams.get("state"); + const completedState = parseOAuthStateResultCookie( + request.cookies.get(resultCookieName)?.value, + ); + + if ( + receivedState && + completedState && + completedState.state === receivedState && + Object.keys(completedState.params).length > 0 + ) { + const deduplicatedRedirect = new URL("/accounts", baseUrl); + for (const [key, value] of Object.entries(completedState.params)) { + deduplicatedRedirect.searchParams.set(key, value); + } + const deduplicatedResponse = NextResponse.redirect(deduplicatedRedirect); + deduplicatedResponse.cookies.delete(stateCookieName); + return deduplicatedResponse; + } + + return null; +} + +interface BuildSuccessRedirectParams { + state: string; + params: Record; + stateCookieName: string; + resultCookieName: string; + baseUrl: string; +} + +/** + * Builds a success redirect response with query params and sets the result cookie + * for deduplication on subsequent requests. + */ +export function buildOAuthSuccessRedirect({ + state, + params, + stateCookieName, + resultCookieName, + baseUrl, +}: BuildSuccessRedirectParams): NextResponse { + const redirectUrl = new URL("/accounts", baseUrl); + for (const [key, value] of Object.entries(params)) { + redirectUrl.searchParams.set(key, value); + } + + const successResponse = NextResponse.redirect(redirectUrl); + successResponse.cookies.delete(stateCookieName); + successResponse.cookies.set( + resultCookieName, + encodeOAuthStateResultCookie({ + state, + params, + }), + oauthStateCookieOptions, + ); + + return successResponse; +} diff --git a/apps/web/utils/oauth/error-handler.ts b/apps/web/utils/oauth/error-handler.ts index f9ff5837c2..99b1c20727 100644 --- a/apps/web/utils/oauth/error-handler.ts +++ b/apps/web/utils/oauth/error-handler.ts @@ -4,23 +4,27 @@ import type { Logger } from "@/utils/logger"; interface ErrorHandlerParams { error: unknown; redirectUrl: URL; - response: NextResponse; stateCookieName: string; logger: Logger; + resultCookieName?: string; } export function handleOAuthCallbackError({ error, redirectUrl, - response, stateCookieName, logger, + resultCookieName, }: ErrorHandlerParams): NextResponse { logger.error("Error in OAuth linking callback:", { error }); const errorMessage = error instanceof Error ? error.message : "Unknown error"; redirectUrl.searchParams.set("error", "link_failed"); redirectUrl.searchParams.set("error_description", errorMessage); + const response = NextResponse.redirect(redirectUrl); response.cookies.delete(stateCookieName); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); + if (resultCookieName) { + response.cookies.delete(resultCookieName); + } + return response; } diff --git a/apps/web/utils/oauth/state.ts b/apps/web/utils/oauth/state.ts index 05f3d56761..4ba6c3862e 100644 --- a/apps/web/utils/oauth/state.ts +++ b/apps/web/utils/oauth/state.ts @@ -47,3 +47,49 @@ export const getMcpPkceCookieName = (integration: IntegrationKey) => export const getMcpOAuthStateType = (integration: IntegrationKey) => `${integration}-mcp`; + +export interface OAuthStateResultCookieValue { + state: string; + params: Record; +} + +export function encodeOAuthStateResultCookie( + value: OAuthStateResultCookieValue, +): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +export function parseOAuthStateResultCookie( + cookieValue: string | undefined, +): OAuthStateResultCookieValue | null { + if (!cookieValue) { + return null; + } + + try { + const parsed = JSON.parse( + Buffer.from(cookieValue, "base64url").toString("utf8"), + ); + + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.state !== "string" || + typeof parsed.params !== "object" || + Array.isArray(parsed.params) + ) { + return null; + } + + const params: Record = {}; + for (const [key, value] of Object.entries(parsed.params)) { + if (typeof key === "string" && typeof value === "string") { + params[key] = value; + } + } + + return { state: parsed.state, params }; + } catch { + return null; + } +} diff --git a/apps/web/utils/outlook/constants.ts b/apps/web/utils/outlook/constants.ts index 9755f8d266..dc1f018993 100644 --- a/apps/web/utils/outlook/constants.ts +++ b/apps/web/utils/outlook/constants.ts @@ -1 +1,3 @@ export const OUTLOOK_LINKING_STATE_COOKIE_NAME = "outlook_linking_state"; +export const OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME = + "outlook_linking_state_result"; diff --git a/version.txt b/version.txt index a1109b5970..355aecc0ea 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.19.2 +v2.19.3