Skip to content

Commit

Permalink
feat: make it possible to update the session (#7056)
Browse files Browse the repository at this point in the history
  • Loading branch information
balazsorban44 committed Mar 29, 2023
1 parent 2954588 commit 2d907f0
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 67 deletions.
131 changes: 129 additions & 2 deletions docs/docs/getting-started/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,133 @@ Because of how `_app` is written, it won't unnecessarily contact the `/api/auth/

More information can be found in the following [GitHub Issue](https://github.com/nextauthjs/next-auth/issues/1210).

### NextAuth.js + React Query
### Updating the session

You can create your own session management solution using data fetching libraries like [React Query](https://tanstack.com/query/v4/docs/adapters/react-query) or [SWR](https://swr.vercel.app). You can use the [original implementation of `@next-auth/react-query`](https://github.com/nextauthjs/react-query) and look at the [`next-auth/react` source code](https://github.com/nextauthjs/next-auth/blob/main/packages/next-auth/src/react/index.tsx) as a starting point.
The `useSession()` hook exposes a `update(data?: any): Promise<Session | null>` method that can be used to update the session, without reloading the page.

You can optionally pass an arbitrary object as the first argument, which will be accessible on the server to merge with the session object.

If you are not passing any argument, the session will be reloaded from the server. (This is useful if you want to update the session after a server-side mutation, like updating in the database.)

:::caution
The data object is coming from the client, so it needs to be validated on the server before saving.
:::

#### Example

```tsx title="pages/profile.tsx"
import { useSession } from "next-auth/react"

export default function Page() {
const { data: session, status, update } = useSession()

if (status === "authenticated") {
return (
<>
<p>Signed in as {session.user.name}</p>

{/* Update the value by sending it to the backend. */}
<button onClick={() => update({ name: "John Doe" })}>
Edit name
</button>
{/*
* Only trigger a session update, assuming you already updated the value server-side.
* All `useSession().data` references will be updated.
*/}
<button onClick={() => update()}>
Edit name
</button>
</>
)
}

return <a href="/api/auth/signin">Sign in</a>
}
```

Assuming a `strategy: "jwt"` is used, the `update()` method will trigger a `jwt` callback with the `trigger: "update"` option. You can use this to update the session object on the server.

```ts title="pages/api/auth/[...nextauth].ts"
...
export default NextAuth({
...
callbacks: {
// Using the `...rest` parameter to be able to narrow down the type based on `trigger`
jwt({ token, trigger, session }) {
if (trigger === "update" && session?.name) {
// Note, that `session` can be any arbitrary object, remember to validate it!
token.name = session
}
return token
}
}
})
```

Assuming a `strategy: "database"` is used, the `update()` method will trigger the `session` callback with the `trigger: "update"` option. You can use this to update the session object on the server.

```ts title="pages/api/auth/[...nextauth].ts"
...
const adapter = PrismaAdapter(prisma)
export default NextAuth({
...
adapter,
callbacks: {
// Using the `...rest` parameter to be able to narrow down the type based on `trigger`
async session({ session, trigger, newSession }) {
// Note, that `rest.session` can be any arbitrary object, remember to validate it!
if (trigger === "update" && newSession?.name) {
// You can update the session in the database if it's not already updated.
// await adapter.updateUser(session.user.id, { name: newSession.name })

// Make sure the updated value is reflected on the client
session.name = newSession.name
}
return session
}
}
})
```

### Refetching the session

[`SessionProvider#refetchInterval`](#refetch-interval) and [`SessionProvider#refetchOnWindowFocus`](#refetch-on-window-focus) can be replaced with the `update()` method too.

:::note
The `update()` method won't sync between tabs as the `refetchInterval` and `refetchOnWindowFocus` options do.
:::

```tsx title="pages/profile.tsx"
import {useEffect} from "react"
import { useSession } from "next-auth/react"

export default function Page() {
const { data: session, status, update } = useSession()

// Polling the session every 1 hour
useEffect(() => {
// TIP: You can also use `navigator.onLine` and some extra event handlers
// to check if the user is online and only update the session if they are.
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
const interval = setInterval(() => update(), 1000 * 60 * 60)
return () => clearInterval(interval)
}, [update])

// Listen for when the page is visible, if the user switches tabs
// and makes our tab visible again, re-fetch the session
useEffect(() => {
const visibilityHandler = () => document.visibilityState === "visible" && update()
window.addEventListener("visibilitychange", visibilityHandler, false)
return () => window.removeEventListener("visibilitychange", visibilityHandler, false)
}, [update])

return (
<pre>
{JSON.stringify(session, null, 2)}
</pre>
)
}
```
---

## getSession()
Expand Down Expand Up @@ -479,6 +602,8 @@ If you are using a custom base path, and your application entry point is not at

#### Refetch interval

See [Session Refetching](#refetching-the-session) for an alternative option.

The `refetchInterval` option can be used to contact the server to avoid a session expiring.

When `refetchInterval` is set to `0` (the default) there will be no session polling.
Expand All @@ -491,6 +616,8 @@ By default, session polling will keep trying, even when the device has no intern

#### Refetch On Window Focus

See [Session Refetching](#refetching-the-session) for an alternative option.

The `refetchOnWindowFocus` option can be used to control whether it automatically updates the session state when you switch a focus on tabs/windows.

When `refetchOnWindowFocus` is set to `true` (the default) tabs/windows will be updated and initialize the components' state when they gain or lose focus.
Expand Down
19 changes: 14 additions & 5 deletions packages/next-auth/src/client/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export interface AuthClientConfig {
}

export interface CtxOrReq {
req?: IncomingMessage
ctx?: { req: IncomingMessage }
req?: Partial<IncomingMessage> & { body?: any }
ctx?: { req: Partial<IncomingMessage> & { body?: any } }
}

/**
Expand All @@ -37,9 +37,18 @@ export async function fetchData<T = any>(
): Promise<T | null> {
const url = `${apiBaseUrl(__NEXTAUTH)}/${path}`
try {
const options = req?.headers.cookie
? { headers: { cookie: req.headers.cookie } }
: {}
const options: RequestInit = {
headers: {
"Content-Type": "application/json",
...(req?.headers?.cookie ? { cookie: req.headers.cookie } : {}),
},
}

if (req?.body) {
options.body = JSON.stringify(req.body)
options.method = "POST"
}

const res = await fetch(url, options)
const data = await res.json()
if (!res.ok) throw data
Expand Down
22 changes: 20 additions & 2 deletions packages/next-auth/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export async function AuthHandler<
} else if (method === "POST") {
switch (action) {
case "signin":
// Verified CSRF Token required for all sign in routes
// Verified CSRF Token required for all sign-in routes
if (options.csrfTokenVerified && options.provider) {
const signin = await routes.signin({
query: req.query,
Expand Down Expand Up @@ -274,7 +274,7 @@ export async function AuthHandler<
return { ...callback, cookies }
}
break
case "_log":
case "_log": {
if (authOptions.logger) {
try {
const { code, level, ...metadata } = req.body ?? {}
Expand All @@ -285,6 +285,24 @@ export async function AuthHandler<
}
}
return {}
}
case "session": {
// Verified CSRF Token required for session updates
if (options.csrfTokenVerified) {
const session = await routes.session({
options,
sessionStore,
newSession: req.body?.data,
isUpdate: true,
})
if (session.cookies) cookies.push(...session.cookies)
return { ...session, cookies } as any
}

// If CSRF token is invalid, return a 400 status code
// we should not redirect to a page as this is an API route
return { status: 400, body: {} as any, cookies }
}
default:
}
}
Expand Down
8 changes: 2 additions & 6 deletions packages/next-auth/src/core/lib/providers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { merge } from "../../utils/merge"

import type { InternalProvider } from "../types"
import type {
OAuthConfigInternal,
OAuthConfig,
Provider,
} from "../../providers"
import type { InternalProvider, OAuthConfigInternal } from "../types"
import type { OAuthConfig, Provider } from "../../providers"
import type { InternalUrl } from "../../utils/parse-url"

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/next-auth/src/core/routes/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export default async function callback(params: {
account,
profile: OAuthProfile,
isNewUser,
trigger: isNewUser ? "signUp" : "signIn",
})

// Encode token
Expand Down Expand Up @@ -269,6 +270,7 @@ export default async function callback(params: {
user,
account,
isNewUser,
trigger: isNewUser ? "signUp" : "signIn",
})

// Encode token
Expand Down Expand Up @@ -393,6 +395,7 @@ export default async function callback(params: {
// @ts-expect-error
account,
isNewUser: false,
trigger: "signIn",
})

// Encode token
Expand Down
55 changes: 31 additions & 24 deletions packages/next-auth/src/core/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { SessionStore } from "../lib/cookie"
interface SessionParams {
options: InternalOptions
sessionStore: SessionStore
isUpdate?: boolean
newSession?: any
}

/**
Expand All @@ -19,7 +21,7 @@ interface SessionParams {
export default async function session(
params: SessionParams
): Promise<ResponseInternal<Session | {}>> {
const { options, sessionStore } = params
const { options, sessionStore, newSession, isUpdate } = params
const {
adapter,
jwt,
Expand All @@ -41,31 +43,37 @@ export default async function session(

if (sessionStrategy === "jwt") {
try {
const decodedToken = await jwt.decode({
...jwt,
token: sessionToken,
const decodedToken = await jwt.decode({ ...jwt, token: sessionToken })

if (!decodedToken) throw new Error("JWT invalid")

// @ts-expect-error
const token = await callbacks.jwt({
token: decodedToken,
...(isUpdate && { trigger: "update" }),
session: newSession,
})

const newExpires = fromDate(sessionMaxAge)

// By default, only exposes a limited subset of information to the client
// as needed for presentation purposes (e.g. "you are logged in as...").
const session = {
user: {
name: decodedToken?.name,
email: decodedToken?.email,
image: decodedToken?.picture,
},
expires: newExpires.toISOString(),
}

// @ts-expect-error
const token = await callbacks.jwt({ token: decodedToken })
// @ts-expect-error
const newSession = await callbacks.session({ session, token })
// @ts-expect-error Property 'user' is missing in type
const updatedSession = await callbacks.session({
session: {
user: {
name: decodedToken?.name,
email: decodedToken?.email,
image: decodedToken?.picture,
},
expires: newExpires.toISOString(),
},
token,
})

// Return session payload as response
response.body = newSession
response.body = updatedSession

// Refresh JWT expiry by re-signing it, with an updated expiry date
const newToken = await jwt.encode({
Expand All @@ -81,7 +89,7 @@ export default async function session(

response.cookies?.push(...sessionCookies)

await events.session?.({ session: newSession, token })
await events.session?.({ session: updatedSession, token })
} catch (error) {
// If JWT not verifiable, make sure the cookie for it is removed and return empty object
logger.error("JWT_SESSION_ERROR", error as Error)
Expand Down Expand Up @@ -123,19 +131,18 @@ export default async function session(
}

// Pass Session through to the session callback
// @ts-expect-error

// @ts-expect-error Property 'token' is missing in type
const sessionPayload = await callbacks.session({
// By default, only exposes a limited subset of information to the client
// as needed for presentation purposes (e.g. "you are logged in as...").
session: {
user: {
name: user.name,
email: user.email,
image: user.image,
},
user: { name: user.name, email: user.email, image: user.image },
expires: session.expires.toISOString(),
},
user,
newSession,
...(isUpdate ? { trigger: "update" } : {}),
})

// Return session payload as response
Expand Down
Loading

1 comment on commit 2d907f0

@vercel
Copy link

@vercel vercel bot commented on 2d907f0 Mar 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.