Files
web/apps/scandic-web/auth.ts
Joakim Jäderberg 8b94540d19 Merged in chore/redirect-counter (pull request #3302)
Counter name is now searchable and add counter for redirects

* refactor: createCounter() only takes one argument, the name of the counter. Makes it easier to search for

* feat: add counter when we do a redirect from redirect-service


Approved-by: Linus Flood
2025-12-08 10:24:05 +00:00

272 lines
7.5 KiB
TypeScript

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 { createCounter } from "@scandic-hotels/common/telemetry"
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"
export const signInCounter = createCounter("auth.signIn")
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<User>
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 counter = signInCounter.init({ type: "curity" })
counter.success()
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)