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 7d15bcec07..c39705123c 100644 --- a/apps/web/app/api/google/linking/auth-url/route.ts +++ b/apps/web/app/api/google/linking/auth-url/route.ts @@ -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, @@ -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, diff --git a/apps/web/app/api/google/linking/callback/route.ts b/apps/web/app/api/google/linking/callback/route.ts index ab93a6b452..f586d9ecbd 100644 --- a/apps/web/app/api/google/linking/callback/route.ts +++ b/apps/web/app/api/google/linking/callback/route.ts @@ -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; @@ -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(); @@ -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; } @@ -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).", { @@ -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, }); } 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 acd1302d76..86e3f8ffdc 100644 --- a/apps/web/app/api/outlook/linking/auth-url/route.ts +++ b/apps/web/app/api/outlook/linking/auth-url/route.ts @@ -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, @@ -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, diff --git a/apps/web/app/api/outlook/linking/callback/route.ts b/apps/web/app/api/outlook/linking/callback/route.ts index caeae4c1bd..b3a243e8c2 100644 --- a/apps/web/app/api/outlook/linking/callback/route.ts +++ b/apps/web/app/api/outlook/linking/callback/route.ts @@ -1,9 +1,7 @@ +import { NextResponse } from "next/server"; import { env } from "@/env"; import prisma from "@/utils/prisma"; -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 { withError } from "@/utils/middleware"; import { captureException, SafeError } from "@/utils/error"; import { validateOAuthCallback } from "@/utils/oauth/callback-validation"; @@ -11,9 +9,12 @@ 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("outlook/linking/callback", async (request) => { const logger = request.logger; @@ -21,19 +22,7 @@ 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; @@ -51,14 +40,32 @@ 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 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(OUTLOOK_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(OUTLOOK_LINKING_STATE_COOKIE_NAME); + return response; + } try { // Exchange code for tokens @@ -159,9 +166,6 @@ 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; } @@ -205,44 +209,76 @@ export const GET = withError("outlook/linking/callback", async (request) => { logger.warn("Failed to fetch profile picture", { error }); } - const newAccount = await prisma.account.create({ - data: { - userId: targetUserId, - type: "oidc", - provider: "microsoft", - providerAccountId: profile.id || providerEmail, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - expires_at: expiresAt, - scope: tokens.scope, - token_type: tokens.token_type, - emailAccount: { - create: { - email: providerEmail, - userId: targetUserId, - name: - profile.displayName || - profile.givenName || - profile.surname || - null, - image: profileImage, + const microsoftProviderAccountId = profile.id || providerEmail; + + try { + const newAccount = await prisma.account.create({ + data: { + userId: targetUserId, + type: "oidc", + provider: "microsoft", + providerAccountId: microsoftProviderAccountId, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: expiresAt, + scope: tokens.scope, + token_type: tokens.token_type, + emailAccount: { + create: { + email: providerEmail, + userId: targetUserId, + name: + profile.displayName || + profile.givenName || + profile.surname || + null, + image: profileImage, + }, }, }, - }, - }); + }); - logger.info("Successfully created and linked new Microsoft account", { - email: providerEmail, - targetUserId, - accountId: newAccount.id, - }); - 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, - }); + logger.info("Successfully created and linked new Microsoft account", { + email: providerEmail, + targetUserId, + accountId: newAccount.id, + }); + } catch (createError: unknown) { + if (isDuplicateError(createError)) { + const accountNow = await prisma.account.findUnique({ + where: { + provider_providerAccountId: { + provider: "microsoft", + providerAccountId: microsoftProviderAccountId, + }, + }, + select: { userId: true }, + }); + + if (accountNow?.userId === targetUserId) { + logger.info( + "Account was created by concurrent request, continuing", + { + targetUserId, + providerAccountId: microsoftProviderAccountId, + }, + ); + } 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(OUTLOOK_LINKING_STATE_COOKIE_NAME); + + return successResponse; } logger.info("Merging Microsoft account (user confirmed).", { @@ -271,19 +307,22 @@ export const GET = withError("outlook/linking/callback", async (request) => { mergeType, }); - return buildOAuthSuccessRedirect({ - state, - params: { success: successMessage }, - stateCookieName: OUTLOOK_LINKING_STATE_COOKIE_NAME, - resultCookieName: OUTLOOK_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(OUTLOOK_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: 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 0c083cb935..debd3795cf 100644 --- a/apps/web/utils/gmail/constants.ts +++ b/apps/web/utils/gmail/constants.ts @@ -14,5 +14,3 @@ 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 deleted file mode 100644 index 0c875dbb8f..0000000000 --- a/apps/web/utils/oauth/callback-helpers.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 99b1c20727..76309c19b2 100644 --- a/apps/web/utils/oauth/error-handler.ts +++ b/apps/web/utils/oauth/error-handler.ts @@ -6,7 +6,6 @@ interface ErrorHandlerParams { redirectUrl: URL; stateCookieName: string; logger: Logger; - resultCookieName?: string; } export function handleOAuthCallbackError({ @@ -14,7 +13,6 @@ export function handleOAuthCallbackError({ redirectUrl, stateCookieName, logger, - resultCookieName, }: ErrorHandlerParams): NextResponse { logger.error("Error in OAuth linking callback:", { error }); const errorMessage = error instanceof Error ? error.message : "Unknown error"; @@ -23,8 +21,5 @@ export function handleOAuthCallbackError({ redirectUrl.searchParams.set("error_description", errorMessage); const response = NextResponse.redirect(redirectUrl); response.cookies.delete(stateCookieName); - 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 4ba6c3862e..05f3d56761 100644 --- a/apps/web/utils/oauth/state.ts +++ b/apps/web/utils/oauth/state.ts @@ -47,49 +47,3 @@ 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 dc1f018993..9755f8d266 100644 --- a/apps/web/utils/outlook/constants.ts +++ b/apps/web/utils/outlook/constants.ts @@ -1,3 +1 @@ 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/apps/web/utils/redis/oauth-code.ts b/apps/web/utils/redis/oauth-code.ts new file mode 100644 index 0000000000..3f68286972 --- /dev/null +++ b/apps/web/utils/redis/oauth-code.ts @@ -0,0 +1,57 @@ +import { redis } from "@/utils/redis"; +import { createHash } from "node:crypto"; + +// Not password hashing - creating a short cache key for OAuth authorization codes +function createOAuthCodeCacheKey(code: string): string { + return createHash("sha256").update(code).digest("hex").slice(0, 16); +} + +function getCodeKey(code: string) { + return `oauth-code:${createOAuthCodeCacheKey(code)}`; +} + +interface OAuthCodeResult { + status: "success"; + params: Record; +} + +export async function acquireOAuthCodeLock(code: string): Promise { + const result = await redis.set(getCodeKey(code), "processing", { + ex: 60, + nx: true, // Only set if key doesn't exist (atomic) + }); + + return result === "OK"; +} + +export async function getOAuthCodeResult( + code: string, +): Promise { + const value = await redis.get(getCodeKey(code)); + + if (!value || value === "processing") { + return null; + } + + if (typeof value === "object" && value.status === "success") { + return value; + } + + return null; +} + +export async function setOAuthCodeResult( + code: string, + params: Record, +): Promise { + const result: OAuthCodeResult = { + status: "success", + params, + }; + + await redis.set(getCodeKey(code), result, { ex: 60 }); +} + +export async function clearOAuthCode(code: string): Promise { + await redis.del(getCodeKey(code)); +}