Files
web/apps/partner-sas/auth.ts
Joakim Jäderberg 9294f0958b Merged in feat/SW-3639-autologin-sas (pull request #3245)
Feat/SW-3639 autologin sas

* wip

* cleanup

* remove commented code and default lang to EN


Approved-by: Anton Gunnarsson
2025-11-28 13:00:42 +00:00

243 lines
6.3 KiB
TypeScript

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")
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", "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 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) {
throw new Error("AuthError: Missing access token")
}
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" })
)
if (error) {
authLogger.error("Failed to fetch EuroBonus profile", error)
}
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)