Skip to content

Commit

Permalink
feat(react): make session requireable in useSession (#2236)
Browse files Browse the repository at this point in the history
A living session could be a requirement for specific pages (like dashboards). If it doesn’t exist, the user should be redirected to a page asking them to sign in again.

Sometimes, a user might log out by accident, or by deleting cookies on purpose. If that happens (e.g. on a separate tab), then `useSession({ required: true })` should detect the absence of a session cookie and always return a non-nullable Session object type.

When `required: true` is set, the default behavior will be to redirect the user to the sign-in page. This can be overridden by an `action()` callback:

```js
const session = useSession({
  required: true,
  action() {
    // ....
  }
})
if (session.status === "Loading") return "Loading or not authenticated..."

// session.data is always defined here.
```

Co-authored-by: Kristóf Poduszló <[email protected]>
Co-authored-by: Lluis Agusti <[email protected]>

BREAKING CHANGE:

The `useSession` hook now returns an object. Here is how to accommodate for this change:

```diff
- const [ session, loading ] = useSession()
+ const { data: session, status } = useSession()
+ const loading = status === "loading"
```

With the new `status` option, you can test states much more clearly.
  • Loading branch information
balazsorban44 authored Jul 5, 2021
1 parent 53e5e37 commit a2e5afa
Show file tree
Hide file tree
Showing 22 changed files with 267 additions and 141 deletions.
38 changes: 30 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ NextAuth.js can be used with or without a database.

### Secure by default

- Promotes the use of passwordless sign in mechanisms
- Designed to be secure by default and encourage best practice for safeguarding user data
- Uses Cross Site Request Forgery Tokens on POST routes (sign in, sign out)
- Promotes the use of passwordless sign-in mechanisms
- Designed to be secure by default and encourage best practices for safeguarding user data
- Uses Cross-Site Request Forgery (CSRF) Tokens on POST routes (sign in, sign out)
- Default cookie policy aims for the most restrictive policy appropriate for each cookie
- When JSON Web Tokens are enabled, they are signed by default (JWS) with HS512
- Use JWT encryption (JWE) by setting the option `encryption: true` (defaults to A256GCM)
- Auto-generates symmetric signing and encryption keys for developer convenience
- Features tab/window syncing and keepalive messages to support short lived sessions
- Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org/)
- Features tab/window syncing and session polling to support short lived sessions
- Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org)

Advanced options allow you to define your own routines to handle controlling what accounts are allowed to sign in, for encoding and decoding JSON Web Tokens and to set custom cookie security policies and session properties, so you can control who is able to sign in and how often sessions have to be re-validated.

Expand All @@ -90,6 +90,7 @@ The package at `@types/next-auth` is now deprecated.
### Add API Route

```javascript
// pages/api/auth/[...nextauth].js
import NextAuth from "next-auth"
import Providers from "next-auth/providers"

Expand All @@ -113,13 +114,15 @@ export default NextAuth({
})
```

### Add React Component
### Add React Hook

The `useSession()` React Hook in the NextAuth.js client is the easiest way to check if someone is signed in.

```javascript
import { useSession, signIn, signOut } from "next-auth/react"

export default function Component() {
const [session, loading] = useSession()
const { data: session } = useSession()
if (session) {
return (
<>
Expand All @@ -137,7 +140,26 @@ export default function Component() {
}
```

## Acknowledgements
### Share/configure session state

Use the `<SessionProvider>` to allows instances of `useSession()` to share the session object across components. It also takes care of keeping the session updated and synced between tabs/windows.

```jsx title="pages/_app.js"
import { SessionProvider } from "next-auth/react"

export default function App({
Component,
pageProps: { session, ...pageProps }
}) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
)
}
```

## Acknowledgments

[NextAuth.js is made possible thanks to all of its contributors.](https://next-auth.js.org/contributors)

Expand Down
4 changes: 2 additions & 2 deletions app/components/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import styles from "./header.module.css"
// component that works on pages which support both client and server side
// rendering, and avoids any flash incorrect content on initial page load.
export default function Header() {
const [session, loading] = useSession()
const { data: session, status } = useSession()

return (
<header>
Expand All @@ -16,7 +16,7 @@ export default function Header() {
<div className={styles.signedInStatus}>
<p
className={`nojs-show ${
!session && loading ? styles.loading : styles.loaded
!session && status === "loading" ? styles.loading : styles.loaded
}`}
>
{!session && (
Expand Down
21 changes: 1 addition & 20 deletions app/pages/_app.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,12 @@
import { SessionProvider } from "next-auth/react"
import "./styles.css"

// Use the <SessionProvider> to improve performance and allow components that call
// `useSession()` anywhere in your application to access the `session` object.
export default function App({
Component,
pageProps: { session, ...pageProps },
}) {
return (
<SessionProvider
// SessionProvider options are not required but can be useful in situations where
// you have a short session maxAge time. Shown here with default values.
// Client Max Age controls how often the useSession in the client should
// contact the server to sync the session state. Value in seconds.
// e.g.
// * 0 - Disabled (always use cache value)
// * 60 - Sync session state with server if it's older than 60 seconds
staleTime={0}
// Keep Alive tells windows / tabs that are signed in to keep sending
// a keep alive request (which extends the current session expiry) to
// prevent sessions in open windows from expiring. Value in seconds.
//
// Note: If a session has expired when keep alive is triggered, all open
// windows / tabs will be updated to reflect the user is signed out.
refetchInterval={0}
session={session}
>
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
)
Expand Down
2 changes: 1 addition & 1 deletion app/pages/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function Page() {
setResponse(response)
}

const [session] = useSession()
const { data: session } = useSession()

if (session) {
return (
Expand Down
2 changes: 1 addition & 1 deletion app/pages/email.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function Page() {
setResponse(response)
}

const [session] = useSession()
const { data: session } = useSession()

if (session) {
return (
Expand Down
20 changes: 6 additions & 14 deletions app/pages/protected.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useState, useEffect } from "react"
import { useSession } from "next-auth/react"
import Layout from "../components/layout"
import AccessDenied from "../components/access-denied"

export default function Page() {
const [session, loading] = useSession()
const { status } = useSession({
required: true,
})
const [content, setContent] = useState()

// Fetch content from protected route
useEffect(() => {
if (status === "loading") return
const fetchData = async () => {
const res = await fetch("/api/examples/protected")
const json = await res.json()
Expand All @@ -17,19 +19,9 @@ export default function Page() {
}
}
fetchData()
}, [session])
}, [status])

// When rendering client side don't display anything until loading is complete
if (typeof window !== "undefined" && loading) return null

// If no session exists, display access denied message
if (!session) {
return (
<Layout>
<AccessDenied />
</Layout>
)
}
if (status === "loading") return <Layout>Loading...</Layout>

// If session exists, display content
return (
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"prepublishOnly": "npm run build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"version:pr": "node ./config/version-pr"
"version:pr": "node ./config/version-pr",
"website": "cd www && npm run start"
},
"files": [
"dist",
Expand Down
30 changes: 22 additions & 8 deletions src/client/__tests__/client-provider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ afterAll(() => {
server.close()
})

test("it won't allow to fetch the session in isolation without a session context", () => {
function App() {
useSession()
return null
}

jest.spyOn(console, "error")
console.error.mockImplementation(() => {})

expect(() => render(<App />)).toThrow(
"useSession must be wrapped in a SessionProvider"
)

console.error.mockRestore()
})

test("fetches the session once and re-uses it for different consumers", async () => {
const sessionRouteCall = jest.fn()

Expand Down Expand Up @@ -66,21 +82,19 @@ test("when there's an existing session, it won't initialize as loading", async (

function ProviderFlow({ options = {} }) {
return (
<>
<SessionProvider {...options}>
<SessionConsumer />
<SessionConsumer testId="2" />
</SessionProvider>
</>
<SessionProvider {...options}>
<SessionConsumer />
<SessionConsumer testId="2" />
</SessionProvider>
)
}

function SessionConsumer({ testId = 1 }) {
const [session, loading] = useSession()
const { data: session, status } = useSession()

return (
<div data-testid={`session-consumer-${testId}`}>
{loading ? "loading" : JSON.stringify(session)}
{status === "loading" ? "loading" : JSON.stringify(session)}
</div>
)
}
41 changes: 38 additions & 3 deletions src/client/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,33 @@ const logger = proxyLogger(_logger, __NEXTAUTH.basePath)
/** @type {import("types/internals/react").SessionContext} */
const SessionContext = React.createContext()

export function useSession() {
return React.useContext(SessionContext)
export function useSession(options = {}) {
const value = React.useContext(SessionContext)

if (process.env.NODE_ENV !== "production" && !value) {
throw new Error("useSession must be wrapped in a SessionProvider")
}

const { required, onUnauthenticated } = options

const requiredAndNotLoading = required && value.status === "unauthenticated"

React.useEffect(() => {
if (requiredAndNotLoading) {
const url = `/api/auth/signin?${new URLSearchParams({
error: "SessionRequired",
callbackUrl: window.location.href,
})}`
if (onUnauthenticated) onUnauthenticated()
else window.location.replace(url)
}
}, [requiredAndNotLoading, onUnauthenticated])

if (requiredAndNotLoading) {
return { data: value.data, status: "loading" }
}

return value
}

export async function getSession(ctx) {
Expand Down Expand Up @@ -269,7 +294,17 @@ export function SessionProvider(props) {
}
}, [props.refetchInterval])

const value = React.useMemo(() => [session, loading], [session, loading])
const value = React.useMemo(
() => ({
data: session,
status: loading
? "loading"
: session
? "authenticated"
: "unauthenticated",
}),
[session, loading]
)

return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
Expand Down
1 change: 1 addition & 0 deletions src/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ async function NextAuthHandler(req, res, userOptions) {
"OAuthAccountNotLinked",
"EmailSignin",
"CredentialsSignin",
"SessionRequired",
].includes(error)
) {
return res.redirect(`${baseUrl}${basePath}/signin?error=${error}`)
Expand Down
1 change: 1 addition & 0 deletions src/server/pages/signin.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default function signin({
EmailSignin: "Check your email inbox.",
CredentialsSignin:
"Sign in failed. Check the details you provided are correct.",
SessionRequired: "Please sign in to access this page.",
default: "Unable to sign in.",
}

Expand Down
10 changes: 9 additions & 1 deletion types/internals/react.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ export interface NextAuthConfig {
_getSession: any
}

export type SessionContext = React.Context<Session>
export type SessionContextValue<R extends boolean = false> = R extends true
?
| { data: Session; status: "authenticated" }
| { data: null; status: "loading" }
:
| { data: Session; status: "authenticated" }
| { data: null; status: "unauthenticated" | "loading" }

export type SessionContext = React.Context<SessionContextValue>
18 changes: 10 additions & 8 deletions types/react-client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from "react"
import { IncomingMessage } from "http"
import { Session } from "."
import { ProviderType } from "./providers"
import { SessionContextValue } from "internals/react"

export interface CtxOrReq {
req?: IncomingMessage
Expand All @@ -17,21 +18,22 @@ export type GetSessionOptions = CtxOrReq & {
triggerEvent?: boolean
}

export interface UseSessionOptions<R extends boolean> {
required: R
/** Defaults to `signIn` */
action?(): void
}

/**
* React Hook that gives you access
* to the logged in user's session data.
*
* [Documentation](https://next-auth.js.org/getting-started/client#usesession)
*/
export function useSession(): [Session | null, boolean]
export function useSession<R extends boolean>(
options?: UseSessionOptions<R>
): SessionContextValue<R>

/**
* Can be called client or server side to return a session asynchronously.
* It calls `/api/auth/session` and returns a promise with a session object,
* or null if no session exists.
*
* [Documentation](https://next-auth.js.org/getting-started/client#getsession)
*/
export function getSession(options?: GetSessionOptions): Promise<Session | null>

/*******************
Expand Down
18 changes: 17 additions & 1 deletion types/tests/react.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,25 @@ const clientSession = {
expires: "1234",
}

// $ExpectType [Session | null, boolean]
/**
* $ExpectType
* | { data: Session; status: "authenticated"; }
* | { data: null; status: "unauthenticated" | "loading"; }
* | { //// data: Session; status: "authenticated"; }
* | { data: null; status: "loading"; }
*/
client.useSession()

// $ExpectType { data: Session; status: "authenticated"; } | { data: null; status: "loading"; }
const session = client.useSession({ required: true })
if (session.status === "loading") {
// $ExpectType null
session.data
} else {
// $ExpectType Session
session.data
}

// $ExpectType Promise<Session | null>
client.getSession({ req: nextReq })

Expand Down
Loading

0 comments on commit a2e5afa

Please sign in to comment.