Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/next-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@
"build": "pnpm clean && pnpm providers && tsc",
"clean": "rm -rf *.js *.d.ts* lib providers",
"dev": "pnpm providers && tsc -w",
"test": "vitest run -c ../utils/vitest.config.ts",
"test:watch": "vitest -c ../utils/vitest.config.ts",
"test": "vitest run -c ./vitest.config.ts",
"test:watch": "vitest -c ./vitest.config.ts",
"test:e2e": "playwright test",
"providers": "node ../utils/scripts/providers"
},
Expand Down Expand Up @@ -107,10 +107,13 @@
}
},
"devDependencies": {
"@testing-library/react": "^14.3.1",
"@types/react": "18.0.37",
"dotenv": "^10.0.0",
"jsdom": "^24.1.3",
"next": "15.5.18",
"nodemailer": "^8.0.5",
"react": "^18.2.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
18 changes: 18 additions & 0 deletions packages/next-auth/src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface AuthClientConfig {
* trigger session updates from places like `signIn` or `signOut`
*/
_getSession: (...args: any[]) => any
/** Lets auth-state changes (`signIn`, `signOut`) abort the in-flight session fetch */
_abort?: AbortController | null
}

export interface UseSessionOptions<R extends boolean> {
Expand Down Expand Up @@ -149,6 +151,7 @@ export async function fetchData<T = any>(
"Content-Type": "application/json",
...(req?.headers?.cookie ? { cookie: req.headers.cookie } : {}),
},
...(req?.signal ? { signal: req.signal } : {}),
}

if (req?.body) {
Expand All @@ -161,11 +164,26 @@ export async function fetchData<T = any>(
if (!res.ok) throw data
return data
} catch (error) {
if ((error as Error)?.name === "AbortError") throw error
logger.error(new ClientFetchError((error as Error).message, error as any))
return null
}
}

/**
* Aborts the in-flight session fetch so its (now stale) result — and the
* rolling session cookie the server attaches to `GET /session` responses —
* is discarded instead of overwriting a newer auth state. Auth-mutating
* requests (`signIn`, `signOut`) call this before and after their POST,
* which narrows the race window rather than closing it: responses whose
* headers already arrived and other tabs' fetches are out of reach.
* @internal
*/
export function abortFetches(__NEXTAUTH: AuthClientConfig) {
__NEXTAUTH._abort?.abort()
__NEXTAUTH._abort = null
}

/** @internal */
export function apiBaseUrl(__NEXTAUTH: AuthClientConfig) {
if (typeof window === "undefined") {
Expand Down
103 changes: 85 additions & 18 deletions packages/next-auth/src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import * as React from "react"
import {
abortFetches,
apiBaseUrl,
ClientSessionError,
fetchData,
Expand Down Expand Up @@ -176,6 +177,11 @@ export interface GetSessionParams {
event?: "storage" | "timer" | "hidden" | string
triggerEvent?: boolean
broadcast?: boolean
/**
* Cancels the session fetch when aborted, in which case the returned
* promise rejects with an `AbortError` instead of resolving.
*/
signal?: AbortSignal
}

export async function getSession(params?: GetSessionParams) {
Expand Down Expand Up @@ -281,6 +287,8 @@ export async function signIn<Redirect extends boolean = true>(
}/${provider}`

const csrfToken = await getCsrfToken()
// A stale session response must not overwrite the new session state.
abortFetches(__NEXTAUTH)
const res = await fetch(
`${signInUrl}?${new URLSearchParams(authorizationParams)}`,
{
Expand All @@ -298,6 +306,8 @@ export async function signIn<Redirect extends boolean = true>(
)

const data = await res.json()
// Also abort fetches that started while the request was running.
abortFetches(__NEXTAUTH)

if (redirect) {
const url = data.url ?? redirectTo
Expand All @@ -310,9 +320,9 @@ export async function signIn<Redirect extends boolean = true>(
const error = new URL(data.url).searchParams.get("error") ?? undefined
const code = new URL(data.url).searchParams.get("code") ?? undefined

if (res.ok) {
await __NEXTAUTH._getSession({ event: "storage" })
}
// Refetch even if the sign-in failed: the aborts above may have discarded
// a legitimate in-flight session update that must be replayed.
await __NEXTAUTH._getSession({ event: "storage" })

return {
error,
Expand Down Expand Up @@ -343,6 +353,9 @@ export async function signOut<R extends boolean = true>(
} = options ?? {}

const baseUrl = apiBaseUrl(__NEXTAUTH)
// A stale session response must not resurrect the session state — or, with
// the JWT strategy, its rolling session cookie.
abortFetches(__NEXTAUTH)
const csrfToken = await getCsrfToken()
const res = await fetch(`${baseUrl}/signout`, {
method: "post",
Expand All @@ -353,6 +366,8 @@ export async function signOut<R extends boolean = true>(
body: new URLSearchParams({ csrfToken, callbackUrl: redirectTo }),
})
const data = await res.json()
// Also abort fetches that started while the request was running.
abortFetches(__NEXTAUTH)

broadcast().postMessage({ event: "session", data: { trigger: "signout" } })

Expand Down Expand Up @@ -406,17 +421,48 @@ export function SessionProvider(props: SessionProviderProps) {
const [loading, setLoading] = React.useState(!hasInitialSession)

React.useEffect(() => {
/**
* Fetches the session and applies it, unless it was superseded while in
* flight — by a newer session fetch, or by an auth-state change
* (`signIn`, `signOut`) aborting it so a stale response cannot overwrite
* the new state. Returns whether the result was applied.
*/
const fetchAndSetSession = async (params?: GetSessionParams) => {
// A newer session fetch always supersedes the previous one, so at most
// one is in flight; aborting the loser also stops its response from
// re-issuing a rolling session cookie.
abortFetches(__NEXTAUTH)
const controller = new AbortController()
__NEXTAUTH._abort = controller
try {
const session = await getSession({
...params,
signal: controller.signal,
})
if (controller.signal.aborted || __NEXTAUTH._abort !== controller) {
return false
}
__NEXTAUTH._session = session
setSession(session)
return true
} finally {
if (__NEXTAUTH._abort === controller) __NEXTAUTH._abort = null
}
}

__NEXTAUTH._getSession = async ({ event } = {}) => {
try {
const storageEvent = event === "storage"
// We should always update if we don't have a client session yet
// or if there are events from other tabs/windows
if (storageEvent || __NEXTAUTH._session === undefined) {
__NEXTAUTH._lastSync = now()
__NEXTAUTH._session = await getSession({
broadcast: !storageEvent,
})
setSession(__NEXTAUTH._session)
// Only an applied fetch may settle `loading`: a superseded one is
// settled by its superseder instead, so an aborted initial fetch
// cannot briefly render an authenticated user as unauthenticated.
if (await fetchAndSetSession({ broadcast: !storageEvent })) {
setLoading(false)
}
return
}

Expand All @@ -438,20 +484,20 @@ export function SessionProvider(props: SessionProviderProps) {

// An event or session staleness occurred, update the client session.
__NEXTAUTH._lastSync = now()
__NEXTAUTH._session = await getSession()
setSession(__NEXTAUTH._session)
if (await fetchAndSetSession()) setLoading(false)
} catch (error) {
if ((error as Error).name === "AbortError") return
logger.error(
new ClientSessionError((error as Error).message, error as any)
)
} finally {
setLoading(false)
}
}

__NEXTAUTH._getSession()

return () => {
abortFetches(__NEXTAUTH)
__NEXTAUTH._lastSync = 0
__NEXTAUTH._session = undefined
__NEXTAUTH._getSession = () => {}
Expand Down Expand Up @@ -513,14 +559,35 @@ export function SessionProvider(props: SessionProviderProps) {
async update(data: any) {
if (loading) return
setLoading(true)
const newSession = await fetchData<Session>(
"session",
__NEXTAUTH,
logger,
typeof data === "undefined"
? undefined
: { body: { csrfToken: await getCsrfToken(), data } }
)
// Tie this fetch to the shared abort so `signIn`/`signOut` can
// discard it: a stale update response must not resurrect the session
// state — or its rolling session cookie — after the auth state
// changed. When discarded, whatever aborted it settles `loading`.
abortFetches(__NEXTAUTH)
const controller = new AbortController()
__NEXTAUTH._abort = controller
let newSession: Session | null
try {
newSession = await fetchData<Session>(
"session",
__NEXTAUTH,
logger,
typeof data === "undefined"
? { signal: controller.signal }
: {
body: { csrfToken: await getCsrfToken(), data },
signal: controller.signal,
}
)
if (controller.signal.aborted || __NEXTAUTH._abort !== controller) {
return null
}
} catch (error) {
if ((error as Error).name === "AbortError") return null
throw error
} finally {
if (__NEXTAUTH._abort === controller) __NEXTAUTH._abort = null
}
setLoading(false)
if (newSession) {
setSession(newSession)
Expand Down
12 changes: 8 additions & 4 deletions packages/next-auth/src/webauthn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiBaseUrl } from "./lib/client.js"
import { abortFetches, apiBaseUrl } from "./lib/client.js"
import { startAuthentication, startRegistration } from "@simplewebauthn/browser"
import { getCsrfToken, getProviders, __NEXTAUTH } from "./react.js"

Expand Down Expand Up @@ -111,6 +111,8 @@ export async function signIn<Redirect extends boolean = true>(
webAuthnBody.action = webAuthnResponse.action

const signInUrl = `${baseUrl}/callback/${provider}?${new URLSearchParams(authorizationParams)}`
// A stale session response must not overwrite the new session state.
abortFetches(__NEXTAUTH)
const res = await fetch(signInUrl, {
method: "post",
headers: {
Expand All @@ -126,6 +128,8 @@ export async function signIn<Redirect extends boolean = true>(
})

const data = await res.json()
// Also abort fetches that started while the request was running.
abortFetches(__NEXTAUTH)

if (redirect) {
const url = data.url ?? callbackUrl
Expand All @@ -138,9 +142,9 @@ export async function signIn<Redirect extends boolean = true>(
const error = new URL(data.url).searchParams.get("error")
const code = new URL(data.url).searchParams.get("code")

if (res.ok) {
await __NEXTAUTH._getSession({ event: "storage" })
}
// Refetch even if the sign-in failed: the aborts above may have discarded
// a legitimate in-flight session update that must be replayed.
await __NEXTAUTH._getSession({ event: "storage" })

return {
error,
Expand Down
Loading
Loading