import NextAuth, { type NextAuthConfig } from "next-auth" import Auth0Provider from "next-auth/providers/auth0" import { dt } from "@scandic-hotels/common/dt" import { createLogger } from "@scandic-hotels/common/logger/createLogger" import { createCounter } from "@scandic-hotels/common/telemetry" import { safeTry } from "@scandic-hotels/common/utils/safeTry" import { getEuroBonusProfileData } from "@scandic-hotels/trpc/routers/partners/sas/getEuroBonusProfile" import { env } from "@/env/server" import type { JWT } from "next-auth/jwt" const authLogger = createLogger("auth") export const PRE_REFRESH_TIME_IN_SECONDS = 180 export const signInCounter = createCounter("auth.signIn") async function refreshTokens(token: JWT) { try { if (!token.refresh_token) { throw "Refresh token missing." } authLogger.debug("Access token expired, trying to refresh it.", { expires_at: token.expires_at, sub: token.sub, token: token.access_token, refreshToken: token.refresh_token, }) const response = await fetch(`${env.SAS_AUTH_ENDPOINT}/oauth/token`, { body: new URLSearchParams({ client_id: env.SAS_AUTH_CLIENTID, client_secret: env.SAS_AUTH_CLIENT_SECRET, grant_type: "refresh_token", refresh_token: token.refresh_token, }), headers: { "Content-Type": "application/x-www-form-urlencoded", }, method: "POST", signal: AbortSignal.timeout(15_000), }) const new_tokens = await response.json() if (!response.ok) { authLogger.debug("Token response was not ok", { status: response.status, statusText: response.statusText, sub: token.sub, resopnse: new_tokens, }) throw new_tokens } authLogger.debug("Successfully got new token(s)", { expires_at: new_tokens.expires_at, got_new_refresh_token: new_tokens.refresh_token !== token.refresh_token, got_new_access_token: new_tokens.access_token !== token.access_token, sub: token.sub, }) const expiresAt = new_tokens.expires_in ? Date.now() + new_tokens.expires_in * 1000 : undefined return { ...token, access_token: new_tokens.access_token, expires_at: expiresAt, refresh_token: new_tokens.refresh_token, } } catch (error) { authLogger.error("Error thrown when trying to refresh", { error, sub: token.sub, }) return { ...token, error: "RefreshAccessTokenError" as const, } } } const sasProvider = Auth0Provider({ id: "sas", name: "SAS", clientId: env.SAS_AUTH_CLIENTID, clientSecret: env.SAS_AUTH_CLIENT_SECRET, issuer: env.SAS_AUTH_ENDPOINT, checks: ["state", "pkce"], authorization: { url: `${env.SAS_AUTH_ENDPOINT}/authorize`, params: { audience: "eb-partner-api", }, }, client: { token_endpoint_auth_method: "client_secret_post", }, }) const config: NextAuthConfig = { basePath: "/api/web/auth", debug: env.NEXTAUTH_DEBUG, trustHost: true, providers: [sasProvider], session: { strategy: "jwt", }, cookies: { sessionToken: { name: "sas-session", }, }, callbacks: { async signIn() { return true }, async jwt(params) { if (params.trigger === "signIn") { const counter = signInCounter.init({ type: "sas" }) const accessToken = params.account?.access_token // expires_at is in seconds for SAS, we need milliseconds const expiresAt = params.account?.expires_at ? params.account.expires_at * 1000 : null if (!accessToken) { counter.fail("missing access token") throw new Error("AuthError: Missing access token") } if (!expiresAt) { counter.fail("missing expiry time") throw new Error("AuthError: Missing expiry time") } const refreshToken = params.account?.refresh_token if (!refreshToken) { counter.fail("missing refresh token") authLogger.warn("⚠️ refreshToken missing") } const [eurobonusProfile, error] = await safeTry( getEuroBonusProfileData({ accessToken, loginType: "eurobonus" }) ) if (error) { authLogger.error("Failed to fetch EuroBonus profile", error) } counter.success() return { ...params.token, isLinked: eurobonusProfile?.linkStatus === "LINKED", loginType: "eurobonus", access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt, } } const expiresAt = dt(params.token.expires_at!) if ( expiresAt .subtract(PRE_REFRESH_TIME_IN_SECONDS, "seconds") .isSameOrBefore(dt()) ) { return await refreshTokens(params.token) } return params.token }, async session({ session, token }) { return { ...session, error: token.error, user: session.user ? { ...session.user, id: token.sub, isLinked: token.isLinked, } : undefined, token: { loginType: "eurobonus", access_token: token.access_token, expires_at: token.expires_at, error: token.error, }, } }, async redirect({ baseUrl, url }) { authLogger.debug(`[redirect callback] deciding redirect URL`, { baseUrl, url, }) if (url.startsWith("/")) { authLogger.debug( `[redirect callback] relative URL accepted, returning: ${baseUrl}${url}` ) // Allows relative callback URLs return `${baseUrl}${url}` } // 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 }, }, } export const { handlers: { GET, POST }, signIn, signOut, } = NextAuth(config) export const { auth } = NextAuth(config)