From 61af689853bf9436266d5b5fe2d35875f19186f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20J=C3=A4derberg?= Date: Fri, 7 Nov 2025 09:59:41 +0000 Subject: [PATCH] Merged in feature/SW-3506-refresh-tokens (pull request #3064) feature(SW-3506): refresh-tokens setup for SAS Eurobonus * feature(SW-3506): refresh-tokens setup for SAS Eurobonus * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/SW-3506-refresh-tokens Approved-by: Anton Gunnarsson --- .../app/[lang]/(auth)/login/route.ts | 2 +- apps/partner-sas/auth.ts | 102 ++++++++++++++++-- apps/partner-sas/env/server.ts | 6 +- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/apps/partner-sas/app/[lang]/(auth)/login/route.ts b/apps/partner-sas/app/[lang]/(auth)/login/route.ts index c90b1a92a..717647524 100644 --- a/apps/partner-sas/app/[lang]/(auth)/login/route.ts +++ b/apps/partner-sas/app/[lang]/(auth)/login/route.ts @@ -62,7 +62,7 @@ export async function GET( /** Record is next-auth typings */ const params = { ui_locales: SAS_LANGUAGE_MAP[contextParams.lang], - scope: ["openid", "profile", "email"].join(" "), + scope: ["openid", "profile", "email", "offline_access"].join(" "), } satisfies Record /** diff --git a/apps/partner-sas/auth.ts b/apps/partner-sas/auth.ts index d1e27a8fb..752994b3e 100644 --- a/apps/partner-sas/auth.ts +++ b/apps/partner-sas/auth.ts @@ -1,32 +1,100 @@ 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 { 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") -/* - TODO: Get info for SAS token timeout and accordingly adjust pre-refresh time move to common/contants - Do we need to handle refresh tokens at all, isn't that handled by the Auth0 provider? - Needs to be verified -*/ +export const PRE_REFRESH_TIME_IN_SECONDS = 180 + +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"], + checks: ["state", "pkce"], authorization: { + url: `${env.SAS_AUTH_ENDPOINT}/authorize`, params: { audience: "eb-partner-api", }, }, client: { - token_endpoint_auth_method: "none", + token_endpoint_auth_method: "client_secret_post", }, }) @@ -62,6 +130,13 @@ const config: NextAuthConfig = { if (!expiresAt) { throw new Error("AuthError: Missing expiry time") } + + const refreshToken = params.account?.refresh_token + + if (!refreshToken) { + authLogger.warn("⚠️ refreshToken missing") + } + const [eurobonusProfile, error] = await safeTry( getEuroBonusProfileData({ accessToken, loginType: "eurobonus" }) ) @@ -75,10 +150,21 @@ const config: NextAuthConfig = { 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()) + ) { + refreshTokens(params.token) + } + return params.token }, async session({ session, token }) { @@ -112,7 +198,7 @@ const config: NextAuthConfig = { // Assume absolute URL try { const parsedUrl = new URL(url) - if (/\.scandichotels\.com$/.test(parsedUrl.hostname)) { + 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 diff --git a/apps/partner-sas/env/server.ts b/apps/partner-sas/env/server.ts index 2dd490b4d..38c40213a 100644 --- a/apps/partner-sas/env/server.ts +++ b/apps/partner-sas/env/server.ts @@ -18,8 +18,9 @@ export const env = createEnv({ .transform((s) => s === "true") .default("false"), PUBLIC_URL: z.string().default(""), - SAS_AUTH_ENDPOINT: z.string().default(""), - SAS_AUTH_CLIENTID: z.string().default(""), + SAS_AUTH_ENDPOINT: z.string(), + SAS_AUTH_CLIENTID: z.string(), + SAS_AUTH_CLIENT_SECRET: z.string(), NEXTAUTH_DEBUG: z .string() .refine((s) => s === "true" || s === "false") @@ -46,6 +47,7 @@ export const env = createEnv({ NEXTAUTH_DEBUG: process.env.NEXTAUTH_DEBUG, PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL, SAS_AUTH_CLIENTID: process.env.SAS_AUTH_CLIENTID, + SAS_AUTH_CLIENT_SECRET: process.env.SAS_AUTH_CLIENT_SECRET, SAS_AUTH_ENDPOINT: process.env.SAS_AUTH_ENDPOINT, SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, SENTRY_SERVER_SAMPLERATE: process.env.SENTRY_SERVER_SAMPLERATE,