import NextAuth, { type NextAuthConfig, type User } from "next-auth" import { LoginTypeEnum } from "@scandic-hotels/common/constants/loginType" import { logger } from "@scandic-hotels/common/logger" import { PRE_REFRESH_TIME_IN_SECONDS } from "@/constants/auth" import { env } from "@/env/server" import type { JWT } from "next-auth/jwt" import type { OIDCConfig } from "next-auth/providers" function getLoginType(user: User) { if (user?.login_with.includes("@")) { return LoginTypeEnum.email } else if (user?.login_with.toLowerCase() == LoginTypeEnum["email link"]) { return LoginTypeEnum["email link"] } else { return LoginTypeEnum["membership number"] } } async function refreshTokens(token: JWT) { try { if (!token.refresh_token) { throw "Refresh token missing." } logger.debug("token-debug Access token expired, trying to refresh it.", { expires_at: token.expires_at, sub: token.sub, token: token.access_token, }) const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, { body: new URLSearchParams({ client_id: env.CURITY_CLIENT_ID_USER, client_secret: env.CURITY_CLIENT_SECRET_USER, 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) { logger.debug("token-debug Token response was not ok", { status: response.status, statusText: response.statusText, sub: token.sub, }) throw new_tokens } logger.debug("token-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) { logger.error("token-debug Error thrown when trying to refresh", { error, sub: token.sub, }) return { ...token, error: "RefreshAccessTokenError" as const, } } } const curityProvider = { id: "curity", name: "Curity", type: "oidc", clientId: env.CURITY_CLIENT_ID_USER, clientSecret: env.CURITY_CLIENT_SECRET_USER, issuer: env.CURITY_ISSUER_SERVICE, authorization: { url: `${env.CURITY_ISSUER_USER}/oauth/v2/authorize?allow=local`, }, token: { url: `${env.CURITY_ISSUER_USER}/oauth/v2/token?allow=local`, }, userinfo: { url: `${env.CURITY_ISSUER_USER}/oauth/v2/userinfo?allow=local`, }, profile(profile: User) { return { id: profile.id, sub: profile.sub, given_name: profile.given_name, login_with: profile.login_with, } }, } satisfies OIDCConfig const baseConfig = { basePath: "/api/web/auth", debug: env.NEXTAUTH_DEBUG, providers: [curityProvider], redirectProxyUrl: env.NEXTAUTH_REDIRECT_PROXY_URL, trustHost: true, session: { strategy: "jwt", }, callbacks: { async signIn() { return true }, async session({ session, token }) { session.error = token.error if (session.user) { return { ...session, token: { expires_at: token.expires_at, error: token.error, }, user: { ...session.user, id: token.sub, }, } } return session }, async redirect({ baseUrl, url }) { logger.debug(`[auth] deciding redirect URL`, { baseUrl, url }) if (url.startsWith("/")) { logger.debug( `[auth] relative URL accepted, returning: ${baseUrl}${url}` ) // Allows relative callback URLs return `${baseUrl}${url}` } else { // Assume absolute URL try { const parsedUrl = new URL(url) if ( /\.scandichotels\.(dk|de|com|fi|no|se)$/.test(parsedUrl.hostname) ) { logger.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 logger.debug(`[auth] origin URL accepted, returning: ${url}`) return url } } catch (e) { logger.error(`[auth] error parsing incoming URL for redirection`, e) } } logger.debug(`[auth] URL denied, returning base URL: ${baseUrl}`) return baseUrl }, async authorized() { return true }, async jwt({ account, session, token, trigger, user, profile }) { const loginType = getLoginType(user) if (trigger === "signIn" && account) { const mfa_scope = profile?.amr == "urn:com:scandichotels:scandic-otp" const tokenExpiry = account.expires_at ? account.expires_at * 1000 : undefined const mfa_expires_at = mfa_scope && tokenExpiry ? tokenExpiry : 0 return { access_token: account.access_token, expires_at: tokenExpiry, refresh_token: account.refresh_token, loginType, mfa_scope: mfa_scope, mfa_expires_at: mfa_expires_at, } } else if ( token.expires_at && Date.now() > token.expires_at - PRE_REFRESH_TIME_IN_SECONDS * 1000 && session?.doRefresh ) { return refreshTokens(token) } return token }, }, // events: { // async signIn() { // console.log("#### SIGNIN EVENT ARGS ######") // console.log(arguments) // console.log("#### END - SIGNIN EVENT ARGS ######") // }, // async signOut() { // console.log("#### SIGNOUT EVENT ARGS ######") // console.log(arguments) // console.log("#### END - SIGNOUT EVENT ARGS ######") // }, // async session() { // console.log("#### SESSION EVENT ARGS ######") // console.log(arguments) // console.log("#### END - SESSION EVENT ARGS ######") // }, // }, // logger: { // error(code, ...message) { // console.info("ERROR LOGGER") // console.error(code, message) // }, // warn(code, ...message) { // console.info("WARN LOGGER") // console.warn(code, message) // }, // debug(code, ...message) { // console.info("DEBUG LOGGER") // console.debug(code, message) // }, // }, } satisfies NextAuthConfig const serverConfig = { ...baseConfig, callbacks: { ...baseConfig.callbacks, async session({ session, token }) { session.error = token.error if (session.user) { return { ...session, token, user: { ...session.user, id: token.sub, }, } } return session }, }, } satisfies NextAuthConfig export const { handlers: { GET, POST }, signIn, signOut, } = NextAuth(baseConfig) export const { auth } = NextAuth(serverConfig)