diff --git a/apps/partner-sas/app/[lang]/SessionRefresher.tsx b/apps/partner-sas/app/[lang]/SessionRefresher.tsx index 44d8be1da..d572a5541 100644 --- a/apps/partner-sas/app/[lang]/SessionRefresher.tsx +++ b/apps/partner-sas/app/[lang]/SessionRefresher.tsx @@ -1,12 +1,57 @@ "use client" -import { signOut, useSession } from "next-auth/react" +import { signIn, signOut, useSession } from "next-auth/react" +import { useEffect, useState } from "react" +import { useLocalStorage } from "usehooks-ts" export function SessionRefresher() { - const session = useSession() - if (session.data?.error === "RefreshAccessTokenError") { - signOut({ redirect: false }) - } + useSilentAuth() + useHandleRefreshError() return null } + +function useHandleRefreshError() { + const session = useSession() + + if (session.data?.error === "RefreshAccessTokenError") { + signOut({ redirect: false }) + } +} + +const SILENT_AUTH_KEY = "silent-auth" +const SILENT_AUTH_EXPIRY = 6 * 60 * 1000 // 6 hours + +function useSilentAuth() { + const { status } = useSession() + const [silentAuthTimestamp, setSilentAuthTimestamp] = useLocalStorage< + string | undefined + >(SILENT_AUTH_KEY, undefined) + const [isLoading, setLoading] = useState(() => status === "unauthenticated") + + const hasCompletedSilentSignin = + !!silentAuthTimestamp && + Date.now() - Number(silentAuthTimestamp) < SILENT_AUTH_EXPIRY + + useEffect(() => { + if (status !== "unauthenticated") return + if (hasCompletedSilentSignin) return + + setLoading(true) + + try { + signIn( + "sas", + {}, + { + prompt: "none", + } + ) + } finally { + setSilentAuthTimestamp(Date.now().toString()) + setLoading(false) + } + }, [hasCompletedSilentSignin, setSilentAuthTimestamp, status]) + + return { isLoading: status !== "authenticated" && isLoading } +} diff --git a/apps/partner-sas/auth.ts b/apps/partner-sas/auth.ts index 6f2afb1f7..3796e31e7 100644 --- a/apps/partner-sas/auth.ts +++ b/apps/partner-sas/auth.ts @@ -115,6 +115,7 @@ const config: NextAuthConfig = { async signIn() { return true }, + async jwt(params) { if (params.trigger === "signIn") { const accessToken = params.account?.access_token @@ -187,34 +188,45 @@ const config: NextAuthConfig = { } }, async redirect({ baseUrl, url }) { - authLogger.debug(`[auth] deciding redirect URL`, { baseUrl, url }) + authLogger.debug(`[redirect callback] deciding redirect URL`, { + baseUrl, + url, + }) if (url.startsWith("/")) { authLogger.debug( - `[auth] relative URL accepted, returning: ${baseUrl}${url}` + `[redirect callback] relative URL accepted, returning: ${baseUrl}${url}` ) // Allows relative callback URLs return `${baseUrl}${url}` - } else { - // Assume absolute URL - try { - const parsedUrl = new URL(url) - if (parsedUrl.hostname.endsWith(".scandichotels.com")) { - authLogger.debug(`[auth] subdomain URL accepted, returning: ${url}`) - // Allows any subdomains on all top level domains above - return url - } else if (parsedUrl.origin === baseUrl) { - // Allows callback URLs on the same origin - authLogger.debug(`[auth] origin URL accepted, returning: ${url}`) - return url - } - } catch (e) { - authLogger.error( - `[auth] error parsing incoming URL for redirection`, - e - ) - } } - authLogger.debug(`[auth] URL denied, returning base URL: ${baseUrl}`) + + // Assume absolute URL + try { + const parsedUrl = new URL(url) + if (parsedUrl.hostname.endsWith(".scandichotels.com")) { + authLogger.debug( + `[redirect callback] subdomain URL accepted, returning: ${url}` + ) + // Allows any subdomains on all top level domains above + return url + } + + if (parsedUrl.origin === baseUrl) { + // Allows callback URLs on the same origin + authLogger.debug( + `[redirect callback] origin URL accepted, returning: ${url}` + ) + return url + } + } catch (e) { + authLogger.error( + `[redirect callback] error parsing incoming URL for redirection`, + e + ) + } + authLogger.debug( + `[redirect callback] URL denied, returning base URL: ${baseUrl}` + ) return baseUrl }, diff --git a/apps/partner-sas/middleware.ts b/apps/partner-sas/middleware.ts index d3c77eb43..0f60f00e3 100644 --- a/apps/partner-sas/middleware.ts +++ b/apps/partner-sas/middleware.ts @@ -1,5 +1,10 @@ import * as Sentry from "@sentry/nextjs" -import { type NextMiddleware, NextResponse } from "next/server" +import { + type NextFetchEvent, + type NextMiddleware, + type NextRequest, + NextResponse, +} from "next/server" import { Lang } from "@scandic-hotels/common/constants/language" import { logger } from "@scandic-hotels/common/logger" @@ -7,6 +12,7 @@ import { findLang } from "@scandic-hotels/common/utils/languages" import * as bookingFlow from "@/middlewares/bookingFlow" import * as invalidUrl from "@/middlewares/invalidUrl" +import * as silentAuthMiddleware from "@/middlewares/silentAuthMiddleware" import * as trailingSlash from "@/middlewares/trailingSlash" import { getDefaultRequestHeaders } from "@/middlewares/utils" @@ -18,10 +24,20 @@ export const middleware: NextMiddleware = async (request, event) => { request.headers.set("x-sh-origin", request.nextUrl.origin) const headers = getDefaultRequestHeaders(request) - const lang = findLang(request.nextUrl.pathname) + const apiMiddlewareResults = await executeMiddlewares({ + request, + event, + defaultHeaders: headers, + middlewares: [silentAuthMiddleware], + }) + if (apiMiddlewareResults) { + return apiMiddlewareResults + } + + const lang = findLang(request.nextUrl.pathname) if (!lang) { - // Lang is required for all our middleware. + // Lang is required for all page middleware. // Without it we shortcircuit early. // Default to English if no lang is found. @@ -34,18 +50,42 @@ export const middleware: NextMiddleware = async (request, event) => { } // Note that the order of middlewares is important since that is the order they are matched by. - const middlewares: { + const pageMiddlewareResults = await executeMiddlewares({ + request, + event, + lang, + defaultHeaders: headers, + middlewares: [invalidUrl, trailingSlash, bookingFlow], + }) + + if (pageMiddlewareResults) { + return pageMiddlewareResults + } + + // Follow through with normal App router rules. + return NextResponse.next({ + request: { + headers, + }, + }) +} + +async function executeMiddlewares({ + middlewares, + request, + event, + lang = Lang.en, + defaultHeaders, +}: { + middlewares: { middleware: NextMiddleware matcher: MiddlewareMatcher - }[] = [ - invalidUrl, - trailingSlash, - // authRequired, - // handleAuth, - bookingFlow, - // cmsContent, - ] - + }[] + request: NextRequest + event: NextFetchEvent + lang?: Lang + defaultHeaders: Headers +}) { try { for (let i = 0; i < middlewares.length; ++i) { const middleware = middlewares[i] @@ -73,7 +113,7 @@ export const middleware: NextMiddleware = async (request, event) => { new URL(`/${lang}/middleware-error/${e.status}`, request.nextUrl), { request: { - headers, + headers: defaultHeaders, }, status: e.status, statusText: e.statusText, @@ -88,20 +128,13 @@ export const middleware: NextMiddleware = async (request, event) => { new URL(`/${lang}/middleware-error/500`, request.nextUrl), { request: { - headers, + headers: defaultHeaders, }, status: 500, statusText: "Internal Server Error", } ) } - - // Follow through with normal App router rules. - return NextResponse.next({ - request: { - headers, - }, - }) } export const config = { @@ -110,5 +143,8 @@ export const config = { * public routes inside middleware. * (https://clerk.com/docs/quickstarts/nextjs?utm_source=sponsorship&utm_medium=youtube&utm_campaign=code-with-antonio&utm_content=12-31-2023#add-authentication-to-your-app) */ - matcher: ["/((?!.+\\.[\\w]+$|_next|_static|.netlify|api|trpc|sitemap).*)"], + matcher: [ + "/((?!.+\\.[\\w]+$|_next|_static|.netlify|api|trpc|sitemap).*)", + "/api/web/auth/callback/sas", + ], } diff --git a/apps/partner-sas/middlewares/silentAuthMiddleware.ts b/apps/partner-sas/middlewares/silentAuthMiddleware.ts new file mode 100644 index 000000000..26ee36094 --- /dev/null +++ b/apps/partner-sas/middlewares/silentAuthMiddleware.ts @@ -0,0 +1,27 @@ +import { + type NextMiddleware, + type NextRequest, + NextResponse, +} from "next/server" + +import type { MiddlewareMatcher } from "./types" + +export const middleware: NextMiddleware = async (req) => { + const redirectUrl = loginRequiredRedirect(req) + if (redirectUrl) { + return NextResponse.redirect(redirectUrl) + } +} + +function loginRequiredRedirect(req: NextRequest) { + if (req.nextUrl.searchParams.get("error") === "login_required") { + return ( + req.cookies.get("next-auth.callback-url")?.value || req.nextUrl.origin + ) + } + return undefined +} + +export const matcher: MiddlewareMatcher = (request) => { + return request.nextUrl.pathname === "/api/web/auth/callback/sas" +}