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
251 lines
6.6 KiB
TypeScript
251 lines
6.6 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 { 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)
|