Merged in feat/SW-3461-setup-auth-with-sas-eurobonus (pull request #2825)

Feat/SW-3461 setup auth with sas eurobonus

* feat(SW-3461): Setup auth for sas eurobonus

* .

* feat: setup auth towards SAS

* Fix auth via SAS and add logout route

* .

* merge

* auth via SAS

* fix powered by scandic logo

* Merge branch 'master' of bitbucket.org:scandic-swap/web into feat/SW-3461-setup-auth-with-sas-eurobonus

* Include access_token in jwt after successful login

* merge


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-09-22 09:30:36 +00:00
parent 9b8ed972ec
commit 8ebc48b138
24 changed files with 494 additions and 76 deletions

View File

@@ -0,0 +1,100 @@
import { type NextRequest, NextResponse } from "next/server"
import { AuthError } from "next-auth"
import { logger } from "@scandic-hotels/common/logger"
import { internalServerError } from "@/server/errors/next"
import { getPublicURL } from "@/server/utils"
import { signIn } from "@/auth"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function GET(
request: NextRequest,
context: { params: Promise<{ lang: Lang }> }
) {
const contextParams = await context.params
const publicURL = getPublicURL(request)
let redirectHeaders: Headers | undefined = undefined
let redirectTo: string
const redirectToCookieValue = request.cookies.get("redirectTo")?.value // Cookie gets set by authRequired middleware
const redirectToSearchParamValue =
request.nextUrl.searchParams.get("redirectTo")
const redirectToFallback = "/"
logger.debug(`[login] redirectTo cookie value: ${redirectToCookieValue}`)
logger.debug(
`[login] redirectTo search param value: ${redirectToSearchParamValue}`
)
redirectTo =
redirectToCookieValue || redirectToSearchParamValue || redirectToFallback
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
logger.debug(`[login] make redirectTo absolute, from ${redirectTo}`)
redirectTo = new URL(redirectTo, publicURL).href
logger.debug(`[login] make redirectTo absolute, to ${redirectTo}`)
}
// Clean up cookie from authRequired middleware
redirectHeaders = new Headers()
redirectHeaders.append(
"set-cookie",
"redirectTo=; Expires=Thu, 01 Jan 1970 00:00:00 UTC; Path=/; HttpOnly; SameSite=Lax"
)
const SAS_LANGUAGE_MAP: Record<Lang, string> = {
no: "nb",
sv: "sv",
fi: "fi",
da: "da",
en: "en",
de: "de",
}
try {
logger.debug(`[login] final redirectUrl: ${redirectTo}`)
/** Record<string, any> is next-auth typings */
const params = {
ui_locales: SAS_LANGUAGE_MAP[contextParams.lang],
scope: ["openid", "profile", "email"].join(" "),
} satisfies Record<string, string>
/**
* Passing `redirect: false` to `signIn` will return the URL instead of
* automatically redirecting to it inside of `signIn`.
* https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L76
*/
const redirectUrl = await signIn(
"sas",
{
redirectTo,
redirect: false,
},
params
)
if (redirectUrl) {
const redirectOpts = {
headers: redirectHeaders,
}
logger.debug(`[login] redirecting to: ${redirectUrl}`, redirectOpts)
return NextResponse.redirect(redirectUrl, redirectOpts)
} else {
logger.error(`[login] missing redirectUrl reponse from signIn()`)
}
} catch (error) {
if (error instanceof AuthError) {
logger.error("signInAuthError", { signInAuthError: error })
} else {
logger.error("signInError", { signInError: error })
}
}
return internalServerError()
}

View File

@@ -0,0 +1,17 @@
import { type NextRequest } from "next/server"
import { getPublicURL } from "@/server/utils"
import { signOut } from "@/auth"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function GET(
request: NextRequest,
_context: { params: Promise<{ lang: Lang }> }
) {
const publicURL = getPublicURL(request)
const redirectTo: string = publicURL
await signOut({ redirectTo, redirect: true })
}

View File

@@ -6,10 +6,9 @@ import "../../globals.css"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import Script from "next/script"
import { SessionProvider } from "next-auth/react"
import { BookingFlowConfig } from "@scandic-hotels/booking-flow/BookingFlowConfig"
import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider"
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
import { NuqsAdapter } from "@scandic-hotels/booking-flow/utils/nuqs"
import { Lang } from "@scandic-hotels/common/constants/language"
import { ToastHandler } from "@scandic-hotels/design-system/ToastHandler"
@@ -25,20 +24,9 @@ import { getMessages } from "@/i18n"
import ClientIntlProvider from "@/i18n/Provider"
import { setLang } from "@/i18n/serverContext"
import { BookingFlowProviders } from "../../components/BookingFlowProviders"
import { Footer } from "../../components/Footer/Footer"
import { Header } from "../../components/Header/Header"
import {
trackAccordionItemOpen,
trackBedSelection,
trackBookingSearchClick,
trackBreakfastSelection,
trackGenericEvent,
trackGlaSaveCardAttempt,
trackLoginClick,
trackOpenSidePeek,
trackPaymentEvent,
trackUpdatePaymentMethod,
} from "../utils/tracking"
import type { Metadata } from "next"
@@ -82,35 +70,18 @@ export default async function RootLayout(props: RootLayoutProps) {
</head>
<body className="scandic">
<div className="root">
<ClientIntlProvider
defaultLocale={Lang.en}
locale={parsedLanguage}
messages={messages}
>
<NuqsAdapter>
<TrpcProvider>
<RACRouterProvider>
<BookingFlowConfig config={bookingFlowConfig}>
<BookingFlowContextProvider
data={{
// TODO
isLoggedIn: false,
}}
>
<BookingFlowTrackingProvider
trackingFunctions={{
trackBookingSearchClick,
trackAccordionItemOpen,
trackOpenSidePeek,
trackGenericEvent,
trackGlaSaveCardAttempt,
trackLoginClick,
trackPaymentEvent,
trackUpdatePaymentMethod,
trackBreakfastSelection,
trackBedSelection,
}}
>
<SessionProvider basePath="/api/web/auth">
<ClientIntlProvider
defaultLocale={Lang.en}
locale={parsedLanguage}
messages={messages}
>
<NuqsAdapter>
{/* TODO handle onError */}
<TrpcProvider>
<RACRouterProvider>
<BookingFlowConfig config={bookingFlowConfig}>
<BookingFlowProviders>
<SiteWideAlert />
<Header />
{props.bookingwidget}
@@ -119,13 +90,13 @@ export default async function RootLayout(props: RootLayoutProps) {
<ToastHandler />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
</BookingFlowTrackingProvider>
</BookingFlowContextProvider>
</BookingFlowConfig>
</RACRouterProvider>
</TrpcProvider>
</NuqsAdapter>
</ClientIntlProvider>
</BookingFlowProviders>
</BookingFlowConfig>
</RACRouterProvider>
</TrpcProvider>
</NuqsAdapter>
</ClientIntlProvider>
</SessionProvider>
</div>
<Script

View File

@@ -0,0 +1,11 @@
import { GET as DEFAULT_GET, POST as DEFAULT_POST } from "@/auth"
import type { NextRequest } from "next/server"
export function GET(req: NextRequest) {
return DEFAULT_GET(req)
}
export function POST(req: NextRequest) {
return DEFAULT_POST(req)
}

127
apps/partner-sas/auth.ts Normal file
View File

@@ -0,0 +1,127 @@
import NextAuth, { type NextAuthConfig } from "next-auth"
import Auth0Provider from "next-auth/providers/auth0"
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { env } from "@/env/server"
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
*/
const sasProvider = Auth0Provider({
id: "sas",
name: "SAS",
clientId: env.SAS_AUTH_CLIENTID,
issuer: env.SAS_AUTH_ENDPOINT,
checks: ["state"],
authorization: {
params: {
audience: "eb-partner-api",
},
},
client: {
token_endpoint_auth_method: "none",
},
})
const config: NextAuthConfig = {
basePath: "/api/web/auth",
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
const expiresAt = params.account?.expires_at
if (!accessToken) {
throw new Error("AuthError: Missing access token")
}
if (!expiresAt) {
throw new Error("AuthError: Missing expiry time")
}
return {
...params.token,
loginType: "sas",
access_token: accessToken,
expires_at: expiresAt,
}
}
return params.token
},
async session({ session, token }) {
return {
...session,
error: token.error,
user: session.user
? {
...session.user,
id: token.sub,
}
: undefined,
token: {
access_token: token.access_token,
expires_at: token.expires_at,
error: token.error,
},
}
},
async redirect({ baseUrl, url }) {
authLogger.debug(`[auth] deciding redirect URL`, { baseUrl, url })
if (url.startsWith("/")) {
authLogger.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\.com$/.test(parsedUrl.hostname)) {
authLogger.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
authLogger.debug(`[auth] origin URL accepted, returning: ${url}`)
return url
}
} catch (e) {
authLogger.error(
`[auth] error parsing incoming URL for redirection`,
e
)
}
}
authLogger.debug(`[auth] URL denied, returning base URL: ${baseUrl}`)
return baseUrl
},
},
}
export const {
handlers: { GET, POST },
signIn,
signOut,
} = NextAuth(config)
export const { auth } = NextAuth(config)

View File

@@ -0,0 +1,45 @@
"use client"
import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider"
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
import { useIsUserLoggedIn } from "../hooks/useIsUserLoggedIn"
import {
trackAccordionItemOpen,
trackBedSelection,
trackBookingSearchClick,
trackBreakfastSelection,
trackGenericEvent,
trackGlaSaveCardAttempt,
trackLoginClick,
trackOpenSidePeek,
trackPaymentEvent,
trackUpdatePaymentMethod,
} from "../utils/tracking"
import type { ReactNode } from "react"
export function BookingFlowProviders({ children }: { children: ReactNode }) {
const isLoggedIn = useIsUserLoggedIn()
return (
<BookingFlowContextProvider data={{ isLoggedIn }}>
<BookingFlowTrackingProvider
trackingFunctions={{
trackBookingSearchClick,
trackAccordionItemOpen,
trackOpenSidePeek,
trackGenericEvent,
trackGlaSaveCardAttempt,
trackLoginClick,
trackPaymentEvent,
trackUpdatePaymentMethod,
trackBreakfastSelection,
trackBedSelection,
}}
>
{children}
</BookingFlowTrackingProvider>
</BookingFlowContextProvider>
)
}

View File

@@ -1,10 +1,22 @@
"use client"
import { useSession } from "next-auth/react"
import Image from "@scandic-hotels/design-system/Image"
import Link from "@scandic-hotels/design-system/Link"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "@/hooks/useLang"
import { PoweredByScandic } from "../PoweredByScandic/PoweredByScandic"
import styles from "./header.module.css"
export function Header() {
const lang = useLang()
const session = useSession()
return (
<>
<header className={styles.header}>
@@ -16,6 +28,29 @@ export function Header() {
width={90}
sizes="100vw"
/>
{session.status === "loading" && (
<SkeletonShimmer width={"12ch"} height={"1ch"} />
)}
{session.status === "unauthenticated" && (
/** For some reason it complains about RSC-payload if using <Link /> */
<a href={`/${lang}/login?redirectTo=${window?.location.href}`}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{"Login here"}
</a>
)}
{session.status === "authenticated" && (
<div>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{session.data?.user && <>{session.data.user.email}</>}
</span>
</Typography>
<Link color={"white"} href={`/${lang}/logout`} prefetch={false}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{"Logout"}
</Link>
</div>
)}
</header>
<div className={styles.poweredBy}>
<PoweredByScandic />

View File

@@ -4,6 +4,7 @@
display: flex;
align-items: center;
padding: 16px;
justify-content: space-between;
@media screen and (min-width: 768px) {
padding: 20px 40px;

View File

@@ -8,6 +8,5 @@
.logo {
max-height: 14px;
max-width: 65px;
height: auto;
width: 100%;
}

View File

@@ -18,6 +18,13 @@ 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(""),
NEXTAUTH_DEBUG: z
.string()
.refine((s) => s === "true" || s === "false")
.transform((s) => s === "true")
.default("false"),
SENTRY_ENVIRONMENT: z.string().default("development"),
SENTRY_SERVER_SAMPLERATE: z.coerce.number().default(0.001),
},
@@ -26,6 +33,9 @@ export const env = createEnv({
ADOBE_SDK_SCRIPT_SRC: process.env.ADOBE_SDK_SCRIPT_SRC,
ENABLE_GTMSCRIPT: process.env.ENABLE_GTMSCRIPT,
PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
SAS_AUTH_ENDPOINT: process.env.SAS_AUTH_ENDPOINT,
SAS_AUTH_CLIENTID: process.env.SAS_AUTH_CLIENTID,
NEXTAUTH_DEBUG: process.env.NEXTAUTH_DEBUG,
SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
SENTRY_SERVER_SAMPLERATE: process.env.SENTRY_SERVER_SAMPLERATE,
},

View File

@@ -0,0 +1,32 @@
import { useSession } from "next-auth/react"
import { logger } from "@scandic-hotels/common/logger"
import type { Session } from "next-auth"
export function useIsUserLoggedIn() {
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
return isUserLoggedIn
}
function isValidClientSession(session: Session | null) {
if (!session) {
return false
}
if (session.error) {
logger.error(`Session error: ${session.error}`)
return false
}
if (session.token.error) {
logger.error(`Session token error: ${session.token.error}`)
return false
}
if (session.token.expires_at && session.token.expires_at < Date.now()) {
logger.error(`Session expired: ${session.token.expires_at}`)
return false
}
return true
}

View File

@@ -28,7 +28,7 @@ const nextConfig: NextConfig = {
],
},
webpack: function (config: any) {
webpack: function (config) {
config.module.rules.push(
{
test: /\.(graphql|gql)/,

View File

@@ -10,6 +10,7 @@
"lint": "next lint --max-warnings 0 && tsc --noEmit",
"lint:fix": "next lint --fix && tsc --noEmit",
"check-types": "tsc --noEmit",
"clean": "rm -rf .next",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:e2e": "playwright test",
@@ -28,6 +29,7 @@
"@swc/plugin-formatjs": "^3.2.2",
"@tanstack/react-query-devtools": "^5.75.5",
"next": "15.3.4",
"next-auth": "5.0.0-beta.29",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-intl": "^7.1.11",

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server"
export function badRequest(cause?: unknown) {
const resInit = {
status: 400,
statusText: "Bad request",
}
return NextResponse.json(
{
cause,
},
resInit
)
}
export function notFound(cause?: unknown) {
const resInit = {
status: 404,
statusText: "Not found",
}
return NextResponse.json(
{
cause,
},
resInit
)
}
export function internalServerError(cause?: unknown) {
const resInit = {
status: 500,
statusText: "Internal Server Error",
}
return NextResponse.json(
{
cause,
},
resInit
)
}
export function serviceUnavailable(cause?: unknown) {
const resInit = {
status: 503,
statusText: "Service Unavailable",
}
return NextResponse.json(
{
cause,
},
resInit
)
}

View File

@@ -1,9 +1,10 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"lint": { "dependsOn": [] },
"build": { "dependsOn": ["include:shared"] },
"dev": { "dependsOn": ["include:shared"] },
"build": { "dependsOn": ["clean", "include:shared"] },
"dev": { "dependsOn": ["clean", "include:shared"] },
"test": {
"dependsOn": [
"@scandic-hotels/trpc#test",
@@ -11,6 +12,9 @@
"@scandic-hotels/booking-flow#test"
]
},
"clean": {
"cache": false
},
"include:shared": {
"outputs": ["public/_static/shared/**"]
}

View File

@@ -23,12 +23,12 @@ export const middleware: NextMiddleware = async (request) => {
? pathWithoutTrailingSlash.replace("/preview", "")
: pathWithoutTrailingSlash
let { contentType, uid, error } =
await getUidAndContentTypeByPath(incomingPathName)
if (error) {
throw internalServerError(error)
const uidAndContent = await getUidAndContentTypeByPath(incomingPathName)
if (uidAndContent.error) {
throw internalServerError(uidAndContent.error)
}
let { contentType, uid } = uidAndContent
const searchParams = new URLSearchParams(request.nextUrl.searchParams)
if (!contentType || !uid) {
@@ -40,7 +40,7 @@ export const middleware: NextMiddleware = async (request) => {
if (incomingPathNameParts.length >= 2) {
const subpage = incomingPathNameParts.pop()
if (subpage) {
let { contentType: parentContentType, uid: parentUid } =
const { contentType: parentContentType, uid: parentUid } =
await getUidAndContentTypeByPath(incomingPathNameParts.join("/"))
if (parentUid) {

View File

@@ -92,6 +92,7 @@ const nextConfig = {
output: "standalone",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
webpack: function (config: any) {
config.module.rules.push(
{

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "yarn clean && next build",
"build": "next build",
"dev": "NODE_OPTIONS=--openssl-legacy-provider PORT=3000 NEXT_PUBLIC_PORT=3000 next dev",
"lint": "yarn clean && next lint --max-warnings 0 && tsc",
"lint:fix": "yarn clean && next lint --fix --max-warnings 0 && tsc",
@@ -78,7 +78,7 @@
"motion": "^12.10.0",
"nanoid": "^5.1.5",
"next": "15.3.4",
"next-auth": "5.0.0-beta.27",
"next-auth": "5.0.0-beta.29",
"react": "19.1.0",
"react-aria-components": "^1.8.0",
"react-day-picker": "^9.6.7",

View File

@@ -1,9 +1,10 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"lint": { "dependsOn": [] },
"build": { "dependsOn": ["include:shared"] },
"dev": { "dependsOn": ["include:shared"] },
"build": { "dependsOn": ["clean", "include:shared"] },
"dev": { "dependsOn": ["clean", "include:shared"] },
"test": {
"dependsOn": [
"@scandic-hotels/trpc#test",
@@ -13,6 +14,9 @@
},
"include:shared": {
"outputs": ["public/_static/shared/**"]
},
"clean": {
"cache": false
}
}
}

View File

@@ -35,7 +35,7 @@ function redeemLocationIsOnSite(
return location === "On-site"
}
function isTierType(type: string): type is "Tier" {
export function isTierType(type: string): type is "Tier" {
return type === "Tier"
}

View File

@@ -3,5 +3,6 @@ export enum LoginTypeEnum {
"membership number" = "membership number",
"email link" = "email link",
"dtmc" = "dtmc",
"sas" = "sas",
}
export type LoginType = keyof typeof LoginTypeEnum

View File

@@ -58,7 +58,7 @@
"graphql-request": "^7.1.2",
"graphql-tag": "^2.12.6",
"json-stable-stringify-without-jsonify": "^1.0.1",
"next-auth": "5.0.0-beta.27",
"next-auth": "5.0.0-beta.29",
"server-only": "^0.0.1",
"slugify": "^1.6.6",
"superjson": "^2.2.2",

View File

@@ -134,9 +134,9 @@ __metadata:
languageName: node
linkType: hard
"@auth/core@npm:0.39.0":
version: 0.39.0
resolution: "@auth/core@npm:0.39.0"
"@auth/core@npm:0.40.0":
version: 0.40.0
resolution: "@auth/core@npm:0.40.0"
dependencies:
"@panva/hkdf": "npm:^1.2.1"
jose: "npm:^6.0.6"
@@ -154,7 +154,7 @@ __metadata:
optional: true
nodemailer:
optional: true
checksum: 10c0/551891eddfcf29bdd4bf614bb0b700a76dbf148af7ead740ecd518d27618a7558a44703edd70ff16298cb54ec2912d7921586a71cb7e233b5d456899345d730b
checksum: 10c0/25cd12f22611eedc21c17dc1908fa9428ae5f0e32eb32c1ab009642276c37099cce58f49ffbb7f8e8d6d6488d5101a24fb9808ec662eee5aca19d520750acaa3
languageName: node
linkType: hard
@@ -6052,6 +6052,7 @@ __metadata:
eslint-plugin-simple-import-sort: "npm:^12.1.1"
graphql-tag: "npm:^2.12.6"
next: "npm:15.3.4"
next-auth: "npm:5.0.0-beta.29"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
react-intl: "npm:^7.1.11"
@@ -6189,7 +6190,7 @@ __metadata:
nanoid: "npm:^5.1.5"
netlify-plugin-cypress: "npm:^2.2.1"
next: "npm:15.3.4"
next-auth: "npm:5.0.0-beta.27"
next-auth: "npm:5.0.0-beta.29"
prettier: "npm:^3.5.3"
react: "npm:19.1.0"
react-aria-components: "npm:^1.8.0"
@@ -6268,7 +6269,7 @@ __metadata:
graphql-request: "npm:^7.1.2"
graphql-tag: "npm:^2.12.6"
json-stable-stringify-without-jsonify: "npm:^1.0.1"
next-auth: "npm:5.0.0-beta.27"
next-auth: "npm:5.0.0-beta.29"
server-only: "npm:^0.0.1"
slugify: "npm:^1.6.6"
superjson: "npm:^2.2.2"
@@ -15550,11 +15551,11 @@ __metadata:
languageName: node
linkType: hard
"next-auth@npm:5.0.0-beta.27":
version: 5.0.0-beta.27
resolution: "next-auth@npm:5.0.0-beta.27"
"next-auth@npm:5.0.0-beta.29":
version: 5.0.0-beta.29
resolution: "next-auth@npm:5.0.0-beta.29"
dependencies:
"@auth/core": "npm:0.39.0"
"@auth/core": "npm:0.40.0"
peerDependencies:
"@simplewebauthn/browser": ^9.0.1
"@simplewebauthn/server": ^9.0.2
@@ -15568,7 +15569,7 @@ __metadata:
optional: true
nodemailer:
optional: true
checksum: 10c0/6f13e4d7d44bf0327cea017d7adb8cf9e9bca32bf030dd147ed5dd8301bcd1cf521b577615454b969643a08389b260347cc83c3e3c421dfa593f3ab7b126295a
checksum: 10c0/2c6bada9a5f28a9a172d3ad295bfb05b648a4fced01f9988154df1ebca712cf460fb49173ada4c26de4c7ab180256f40ac19d16e2147c1c68f2a7475ab5d5ea8
languageName: node
linkType: hard