import { type NextMiddleware, NextResponse } from "next/server" import { logger } from "@scandic-hotels/common/logger" import { findLang } from "@scandic-hotels/common/utils/languages" import { resolve as resolveEntry } from "@scandic-hotels/trpc/utils/entry" import { loyaltyPagesWebviews, myPagesWebviews, myStayWebviews, refreshWebviews, webviews, } from "@/constants/routes/webviews" import { env } from "@/env/server" import { badRequest, internalServerError, notFound } from "@/server/errors/next" import { decryptData } from "@/utils/aes" import { getDefaultRequestHeaders } from "./utils" import type { Lang } from "@scandic-hotels/common/constants/language" import type { MiddlewareMatcher } from "@/types/middleware" export const middleware: NextMiddleware = async (request) => { const { nextUrl } = request const lang = findLang(nextUrl.pathname)! const loginTypeHeader = request.headers.get("loginType") const loginTypeSearchParam = nextUrl.searchParams.get("loginType") const adobeMc = nextUrl.searchParams.get("adobe_mc") const headers = getDefaultRequestHeaders(request) // LoginType is passed from the mobile app as a header and needs to be passed around with each subsequent // request within webviews due to tracking. We set the loginType as a header and pass it along to future // requests, and to read it from TRPC side (which is where the tracking object is created). // The value is appended to all webview links as a search param, just like adobe_mc. const loginType = loginTypeHeader || loginTypeSearchParam if (loginType) { headers.set("loginType", loginType) } // adobe_mc (Experience Cloud ID) needs to be passed around as a search param, which will be read // from the URL by the tracking SDK. Adobe_mc is passed from the mobile app as a searchParam, and // then passed to nextjs as a header. In the RSC, the adobe_mc is appended as a searchParam on each webview link. if (adobeMc) { headers.set("adobe_mc", adobeMc) } // If user is redirected to /lang/webview/refresh/, the webview token is invalid and we remove the cookie if (refreshWebviews.includes(nextUrl.pathname)) { const res = NextResponse.rewrite( new URL( `/${lang}/webview/refresh?${nextUrl.searchParams.toString()}`, nextUrl ), { request: { headers, }, } ) res.cookies.delete("webviewToken") return res } const authorizationToken = request.headers.get("X-Authorization") const webviewTokenCookie = request.cookies.get("webviewToken") try { if (webviewTokenCookie && !authorizationToken) { // If the webviewToken cookie is present but no authorization token is provided // we can skip the decryption and see if our cookie is valid. // This handles when the app is navigating between pages inside the webview return handleWebviewRewrite({ nextUrl, headers, decryptedData: null, lang, setCookie: false, }) } // Authorization header is required for webviews // It should be base64 encoded if (!authorizationToken) { logger.error("Authorization header is missing") return badRequest("Authorization header is missing") } // Initialization vector header is required for webviews // It should be base64 encoded const initializationVector = request.headers.get("X-AES-IV")! if (!initializationVector) { logger.error("initializationVector header is missing") return badRequest("initializationVector header is missing") } const decryptedData = await decryptData( env.WEBVIEW_ENCRYPTION_KEY, initializationVector, authorizationToken ) if (webviewTokenCookie && webviewTokenCookie.value === decryptedData) { // If the webviewToken cookie is present and matches the authorization token, // we can skip decryption and just rewrite the request with the existing cookie. return handleWebviewRewrite({ nextUrl, headers, decryptedData: null, lang, setCookie: false, }) } return handleWebviewRewrite({ nextUrl, headers, decryptedData, lang, setCookie: true, }) } catch (e) { if (e instanceof Error) { logger.error(`Error in webView middleware - ${e.name}: ${e.message}`) } return badRequest() } } async function handleWebviewRewrite({ nextUrl, headers, decryptedData, lang, setCookie, }: { nextUrl: URL headers: Headers decryptedData: string | null lang: Lang setCookie: boolean }) { const path = nextUrl.pathname if (myStayWebviews.includes(path)) { const res = NextResponse.next({ request: { headers }, }) if (decryptedData && setCookie) { res.cookies.set("webviewToken", decryptedData, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 60 * 30, // 30 minutes }) } return res } const pathNameWithoutLang = path.replace(`/${lang}/webview`, "") const { uid, error } = await resolveEntry(pathNameWithoutLang, lang) if (error) { throw internalServerError(error) } if (uid) { headers.set("x-uid", uid) } if (myPagesWebviews.includes(path)) { const res = NextResponse.rewrite( new URL(`/${lang}/webview/account-page/${uid}`, nextUrl), { request: { headers }, } ) if (decryptedData && setCookie) { res.cookies.set("webviewToken", decryptedData, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 60 * 30, // 30 minutes }) } return res } if (loyaltyPagesWebviews.includes(path)) { const res = NextResponse.rewrite( new URL(`/${lang}/webview/loyalty-page/${uid}`, nextUrl), { request: { headers }, } ) if (decryptedData && setCookie) { res.cookies.set("webviewToken", decryptedData, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 60 * 30, // 30 minutes }) } return res } return notFound() } export const matcher: MiddlewareMatcher = (request) => { const { nextUrl } = request return webviews.includes(nextUrl.pathname) }