Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions apps/web/app/api/google/linking/auth-url/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { NextResponse } from "next/server";
import { withAuth } from "@/utils/middleware";
import { getLinkingOAuth2Client } from "@/utils/gmail/client";
import {
GOOGLE_LINKING_STATE_COOKIE_NAME,
GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME,
} from "@/utils/gmail/constants";
import { GOOGLE_LINKING_STATE_COOKIE_NAME } from "@/utils/gmail/constants";
import { SCOPES } from "@/utils/gmail/scopes";
import {
generateOAuthState,
Expand Down Expand Up @@ -34,7 +31,6 @@ 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,
Expand Down
177 changes: 108 additions & 69 deletions apps/web/app/api/google/linking/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,25 @@
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,
GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME,
} from "@/utils/gmail/constants";
import { GOOGLE_LINKING_STATE_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";
acquireOAuthCodeLock,
getOAuthCodeResult,
setOAuthCodeResult,
clearOAuthCode,
} from "@/utils/redis/oauth-code";
import { isDuplicateError } from "@/utils/prisma-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;
Expand All @@ -48,14 +37,32 @@ 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 cachedResult = await getOAuthCodeResult(code);
if (cachedResult) {
logger.info("OAuth code already processed, returning cached result", {
targetUserId,
});
const redirectUrl = new URL("/accounts", request.nextUrl.origin);
for (const [key, value] of Object.entries(cachedResult.params)) {
redirectUrl.searchParams.set(key, value);
}
const response = NextResponse.redirect(redirectUrl);
response.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME);
return response;
}

const { targetUserId, code } = validation;
const state = receivedState;
const baseRedirectUrl = new URL("/accounts", request.nextUrl.origin);
const acquiredLock = await acquireOAuthCodeLock(code);
if (!acquiredLock) {
logger.info("OAuth code is being processed by another request", {
targetUserId,
});
const redirectUrl = new URL("/accounts", request.nextUrl.origin);
const response = NextResponse.redirect(redirectUrl);
response.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME);
return response;
}

const googleAuth = getLinkingOAuth2Client();

Expand Down Expand Up @@ -125,9 +132,6 @@ 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;
}

Expand All @@ -137,41 +141,73 @@ export const GET = withError("google/linking/callback", async (request) => {
targetUserId,
});

const newAccount = await prisma.account.create({
data: {
userId: targetUserId,
type: "oidc",
provider: "google",
providerAccountId,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expiry_date ? new Date(tokens.expiry_date) : null,
scope: tokens.scope,
token_type: tokens.token_type,
id_token: tokens.id_token,
emailAccount: {
create: {
email: providerEmail,
userId: targetUserId,
name: payload.name || null,
image: payload.picture,
try {
const newAccount = await prisma.account.create({
data: {
userId: targetUserId,
type: "oidc",
provider: "google",
providerAccountId,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expiry_date
? new Date(tokens.expiry_date)
: null,
scope: tokens.scope,
token_type: tokens.token_type,
id_token: tokens.id_token,
emailAccount: {
create: {
email: providerEmail,
userId: targetUserId,
name: payload.name || null,
image: payload.picture,
},
},
},
},
});
});

logger.info("Successfully created and linked new Google account", {
email: providerEmail,
targetUserId,
accountId: newAccount.id,
});
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,
});
logger.info("Successfully created and linked new Google account", {
email: providerEmail,
targetUserId,
accountId: newAccount.id,
});
} catch (createError: unknown) {
if (isDuplicateError(createError)) {
const accountNow = await prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId,
},
},
select: { userId: true },
});

if (accountNow?.userId === targetUserId) {
logger.info(
"Account was created by concurrent request, continuing",
{
targetUserId,
providerAccountId,
},
);
} else {
throw createError;
}
} else {
throw createError;
}
}

await setOAuthCodeResult(code, { success: "account_created_and_linked" });

const successUrl = new URL("/accounts", request.nextUrl.origin);
successUrl.searchParams.set("success", "account_created_and_linked");
const successResponse = NextResponse.redirect(successUrl);
successResponse.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME);

return successResponse;
}

logger.info("Merging Google account (user confirmed).", {
Expand Down Expand Up @@ -202,19 +238,22 @@ export const GET = withError("google/linking/callback", async (request) => {
mergeType,
});

return buildOAuthSuccessRedirect({
state,
params: { success: successMessage },
stateCookieName: GOOGLE_LINKING_STATE_COOKIE_NAME,
resultCookieName: GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME,
baseUrl: request.nextUrl.origin,
});
await setOAuthCodeResult(code, { success: successMessage });

const successUrl = new URL("/accounts", request.nextUrl.origin);
successUrl.searchParams.set("success", successMessage);
const successResponse = NextResponse.redirect(successUrl);
successResponse.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME);

return successResponse;
} catch (error) {
await clearOAuthCode(code);

const errorUrl = new URL("/accounts", request.nextUrl.origin);
return handleOAuthCallbackError({
error,
redirectUrl: baseRedirectUrl,
redirectUrl: errorUrl,
stateCookieName: GOOGLE_LINKING_STATE_COOKIE_NAME,
resultCookieName: GOOGLE_LINKING_STATE_RESULT_COOKIE_NAME,
logger,
});
}
Expand Down
6 changes: 1 addition & 5 deletions apps/web/app/api/outlook/linking/auth-url/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { NextResponse } from "next/server";
import { withAuth } from "@/utils/middleware";
import { getLinkingOAuth2Url } from "@/utils/outlook/client";
import {
OUTLOOK_LINKING_STATE_COOKIE_NAME,
OUTLOOK_LINKING_STATE_RESULT_COOKIE_NAME,
} from "@/utils/outlook/constants";
import { OUTLOOK_LINKING_STATE_COOKIE_NAME } from "@/utils/outlook/constants";
import {
generateOAuthState,
oauthStateCookieOptions,
Expand All @@ -27,7 +24,6 @@ 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,
Expand Down
Loading
Loading