refactor(auth-js): session provider (#775)

* refactor: session provider

* format
pull/786/head
Divyam 2024-10-21 03:53:51 +05:30 committed by GitHub
parent 9506e3c424
commit c19b51baaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 383 additions and 429 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/auth-js': patch
---
refactor session provider

View File

@ -16,78 +16,49 @@ Before starting using the middleware you must set the following environment vari
```plain
AUTH_SECRET=#required
AUTH_URL=#optional
AUTH_URL=https://example.com/api/auth
```
## How to Use
```ts
import { Hono, Context } from 'hono'
import { authHandler, initAuthConfig, verifyAuth, type AuthConfig } from "@hono/auth-js"
import GitHub from "@auth/core/providers/github"
import { Hono } from 'hono'
import { authHandler, initAuthConfig, verifyAuth } from '@hono/auth-js'
import GitHub from '@auth/core/providers/github'
const app = new Hono()
app.use("*", initAuthConfig(getAuthConfig))
app.use("/api/auth/*", authHandler())
app.use('/api/*', verifyAuth())
app.get('/api/protected', (c) => {
const auth = c.get("authUser")
return c.json(auth)
})
function getAuthConfig(c: Context): AuthConfig {
return {
app.use(
'*',
initAuthConfig((c) => ({
secret: c.env.AUTH_SECRET,
providers: [
GitHub({
clientId: c.env.GITHUB_ID,
clientSecret: c.env.GITHUB_SECRET
clientSecret: c.env.GITHUB_SECRET,
}),
]
}
}
],
}))
)
app.use('/api/auth/*', authHandler())
app.use('/api/*', verifyAuth())
app.get('/api/protected', (c) => {
const auth = c.get('authUser')
return c.json(auth)
})
export default app
```
React component
```tsx
import { SessionProvider } from "@hono/auth-js/react"
export default function App() {
return (
<SessionProvider>
<Children />
</SessionProvider>
)
}
function Children() {
const { data: session, status } = useSession()
return (
<div >
I am {session?.user}
</div>
)
}
```
Default `/api/auth` path can be changed to something else but that will also require you to change path in react app.
```tsx
import {SessionProvider,authConfigManager,useSession } from "@hono/auth-js/react"
import { SessionProvider, useSession } from '@hono/auth-js/react'
authConfigManager.setConfig({
baseUrl: '', //needed for cross domain setup.
basePath: '/custom', // if auth route is diff from /api/auth
credentials:'same-origin' //needed for cross domain setup
});
export default function App() {
export default function App() {
return (
<SessionProvider>
<Children />
@ -97,45 +68,50 @@ export default function App() {
function Children() {
const { data: session, status } = useSession()
return <div>I am {session?.user}</div>
}
```
Default `/api/auth` path can be changed to something else but that will also require you to change path in react app.
```tsx
import { SessionProvider, authConfigManager, useSession } from '@hono/auth-js/react'
authConfigManager.setConfig({
basePath: '/custom', // if auth route is diff from /api/auth
})
export default function App() {
return (
<div >
I am {session?.user}
</div>
<SessionProvider>
<Children />
</SessionProvider>
)
}
```
For cross domain setup as mentioned above you need to set these cors headers in hono along with change in same site cookie attribute.[Read More Here](https://next-auth.js.org/configuration/options#cookies)
``` ts
app.use(
"*",
cors({
origin: (origin) => origin,
allowHeaders: ["Content-Type"],
credentials: true,
})
)
```
SessionProvider is not needed with react query.This wrapper is enough
```ts
const useSession = ()=>{
const { data ,status } = useQuery({
queryKey: ["session"],
queryFn: async () => {
const res = await fetch("/api/auth/session")
return res.json();
},
staleTime: 5 * (60 * 1000),
gcTime: 10 * (60 * 1000),
refetchOnWindowFocus: true,
})
return { session:data, status }
function Children() {
const { data: session, status } = useSession()
return <div>I am {session?.user}</div>
}
```
SessionProvider is not needed with react query.Use useQuery hook to fetch session data.
```ts
const useSession = () => {
const { data, status } = useQuery({
queryKey: ['session'],
queryFn: async () => {
const res = await fetch('/api/auth/session')
return res.json()
},
staleTime: 5 * (60 * 1000),
gcTime: 10 * (60 * 1000),
refetchOnWindowFocus: true,
})
return { session: data, status }
}
```
> [!WARNING]
> You can't use event updates which SessionProvider provides and session will not be in sync across tabs if you use react query wrapper but in RQ5 you can enable this using Broadcast channel (see RQ docs).
Working example repo https://github.com/divyam234/next-auth-hono-react

View File

@ -1,19 +1,24 @@
import { AuthError } from '@auth/core/errors'
import type { BuiltInProviderType, ProviderType } from '@auth/core/providers'
import type { LoggerInstance, Session } from '@auth/core/types'
import * as React from 'react'
import { useEffect, useState } from 'react'
class ClientFetchError extends AuthError {}
export class ClientSessionError extends AuthError {}
export interface GetSessionParams {
event?: 'storage' | 'timer' | 'hidden' | string
triggerEvent?: boolean
}
export interface AuthClientConfig {
baseUrl: string
basePath: string
credentials?: RequestCredentials
_session?: Session | null | undefined
_lastSync: number
_getSession: (...args: any[]) => any
credentials: RequestCredentials
lastSync: number
session: Session | null
fetchSession: (params?: GetSessionParams) => Promise<void>
}
export interface UseSessionOptions<R extends boolean> {
@ -32,13 +37,7 @@ export interface ClientSafeProvider {
}
export interface SignInOptions extends Record<string, unknown> {
/**
* Specify to which URL the user will be redirected after signing in. Defaults to the page URL the sign-in is initiated from.
*
* [Documentation](https://next-auth.js.org/getting-started/client#specifying-a-callbackurl)
*/
callbackUrl?: string
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option) */
redirect?: boolean
}
@ -60,26 +59,39 @@ export interface SignOutResponse {
}
export interface SignOutParams<R extends boolean = true> {
/** [Documentation](https://next-auth.js.org/getting-started/client#specifying-a-callbackurl-1) */
callbackUrl?: string
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1 */
redirect?: R
}
export interface SessionProviderProps {
children: React.ReactNode
session?: Session | null
baseUrl?: string
basePath?: string
refetchInterval?: number
refetchOnWindowFocus?: boolean
refetchWhenOffline?: false
}
export type UpdateSession = (data?: any) => Promise<Session | null>
export type SessionContextValue<R extends boolean = false> = R extends true
?
| { update: UpdateSession; data: Session; status: 'authenticated' }
| { update: UpdateSession; data: null; status: 'loading' }
:
| { update: UpdateSession; data: Session; status: 'authenticated' }
| {
update: UpdateSession
data: null
status: 'unauthenticated' | 'loading'
}
export async function fetchData<T = any>(
path: string,
config: AuthClientConfig,
config: {
baseUrl: string
basePath: string
credentials: RequestCredentials
},
logger: LoggerInstance,
req: any = {}
): Promise<T | null> {
@ -111,20 +123,22 @@ export async function fetchData<T = any>(
}
export function useOnline() {
const [isOnline, setIsOnline] = React.useState(
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : false
)
React.useEffect(() => {
useEffect(() => {
const abortController = new AbortController()
const { signal } = abortController
const setOnline = () => setIsOnline(true)
const setOffline = () => setIsOnline(false)
window.addEventListener('online', setOnline)
window.addEventListener('offline', setOffline)
window.addEventListener('online', setOnline, { signal })
window.addEventListener('offline', setOffline, { signal })
return () => {
window.removeEventListener('online', setOnline)
window.removeEventListener('offline', setOffline)
abortController.abort()
}
}, [])
@ -136,11 +150,11 @@ export function now() {
}
export function parseUrl(url?: string) {
const defaultUrl = 'http://localhost:3000/api/auth';
const parsedUrl = new URL(url?.startsWith('http') ? url : `https://${url}` || defaultUrl);
const defaultUrl = 'http://localhost:3000/api/auth'
const parsedUrl = new URL(url?.startsWith('http') ? url : `https://${url}` || defaultUrl)
const path = parsedUrl.pathname === '/' ? '/api/auth' : parsedUrl.pathname.replace(/\/$/, '');
const base = `${parsedUrl.origin}${path}`;
const path = parsedUrl.pathname === '/' ? '/api/auth' : parsedUrl.pathname.replace(/\/$/, '')
const base = `${parsedUrl.origin}${path}`
return {
origin: parsedUrl.origin,
@ -148,5 +162,5 @@ export function parseUrl(url?: string) {
path,
base,
toString: () => base,
};
}
}

View File

@ -44,13 +44,10 @@ async function cloneRequest(input: URL | string, request: Request, headers?: Hea
headers: headers ?? new Headers(request.headers),
body:
request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.blob(),
// @ts-ignore: TS2353
referrer: 'referrer' in request ? (request.referrer as string) : undefined,
// deno-lint-ignore no-explicit-any
referrerPolicy: request.referrerPolicy as any,
referrerPolicy: request.referrerPolicy,
mode: request.mode,
credentials: request.credentials,
// @ts-ignore: TS2353
cache: request.cache,
redirect: request.redirect,
integrity: request.integrity,
@ -66,25 +63,26 @@ export async function reqWithEnvUrl(req: Request, authUrl?: string) {
const reqUrlObj = new URL(req.url)
const authUrlObj = new URL(authUrl)
const props = ['hostname', 'protocol', 'port', 'password', 'username'] as const
props.forEach((prop) => (reqUrlObj[prop] = authUrlObj[prop]))
return cloneRequest(reqUrlObj.href, req)
} else {
const url = new URL(req.url)
const headers = new Headers(req.headers)
const proto = headers.get('x-forwarded-proto')
const host = headers.get('x-forwarded-host') ?? headers.get('host')
if (proto != null) url.protocol = proto.endsWith(':') ? proto : proto + ':'
if (host != null) {
url.host = host
const portMatch = host.match(/:(\d+)$/)
if (portMatch) url.port = portMatch[1]
else url.port = ''
headers.delete('x-forwarded-host')
headers.delete('Host')
headers.set('Host', host)
for (const prop of props) {
if (authUrlObj[prop]) reqUrlObj[prop] = authUrlObj[prop]
}
return cloneRequest(url.href, req, headers)
return cloneRequest(reqUrlObj.href, req)
}
const url = new URL(req.url)
const headers = new Headers(req.headers)
const proto = headers.get('x-forwarded-proto')
const host = headers.get('x-forwarded-host') ?? headers.get('host')
if (proto != null) url.protocol = proto.endsWith(':') ? proto : `${proto}:`
if (host != null) {
url.host = host
const portMatch = host.match(/:(\d+)$/)
if (portMatch) url.port = portMatch[1]
else url.port = ''
headers.delete('x-forwarded-host')
headers.delete('Host')
headers.set('Host', host)
}
return cloneRequest(url.href, req, headers)
}
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
@ -114,7 +112,7 @@ export async function getAuthUser(c: Context): Promise<AuthUser | null> {
const session = (await response.json()) as Session | null
return session && session.user ? authUser : null
return session?.user ? authUser : null
}
export function verifyAuth(): MiddlewareHandler {
@ -126,9 +124,8 @@ export function verifyAuth(): MiddlewareHandler {
status: 401,
})
throw new HTTPException(401, { res })
} else {
c.set('authUser', authUser)
}
c.set('authUser', authUser)
await next()
}

View File

@ -1,42 +1,50 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { BuiltInProviderType, RedirectableProviderType } from '@auth/core/providers'
import type { LoggerInstance, Session } from '@auth/core/types'
import * as React from 'react'
import { ClientSessionError, fetchData, now, parseUrl, useOnline } from './client'
import type {
AuthClientConfig,
ClientSafeProvider,
LiteralUnion,
SessionProviderProps,
SignInAuthorizationParams,
SignInOptions,
SignInResponse,
SignOutParams,
SignOutResponse,
UseSessionOptions,
import {
type AuthClientConfig,
ClientSessionError,
fetchData,
now,
parseUrl,
useOnline,
type SessionContextValue,
type SessionProviderProps,
type GetSessionParams,
type UseSessionOptions,
type LiteralUnion,
type SignInOptions,
type SignInAuthorizationParams,
type SignInResponse,
type ClientSafeProvider,
type SignOutParams,
type SignOutResponse,
} from './client'
import type { LoggerInstance, Session } from '@auth/core/types'
import { useContext, useEffect, useMemo } from 'react'
import type { BuiltInProviderType, RedirectableProviderType } from '@auth/core/providers'
// TODO: Remove/move to core?
export type {
LiteralUnion,
SignInOptions,
SignInAuthorizationParams,
SignOutParams,
SignInResponse,
const logger: LoggerInstance = {
debug: console.debug,
error: console.error,
warn: console.warn,
}
export { SessionProviderProps }
class AuthConfigManager {
private static instance: AuthConfigManager | null = null
_config: AuthClientConfig = {
baseUrl: typeof window !== 'undefined' ? parseUrl(window.location.origin).origin : '',
basePath: typeof window !== 'undefined' ? parseUrl(window.location.origin).path : '/api/auth',
credentials: 'same-origin',
_lastSync: 0,
_session: undefined,
_getSession: () => {},
private config: AuthClientConfig
private constructor() {
this.config = this.createDefaultConfig()
}
private createDefaultConfig(): AuthClientConfig {
return {
baseUrl: typeof window !== 'undefined' ? parseUrl(window.location.origin).origin : '',
basePath: typeof window !== 'undefined' ? parseUrl(window.location.origin).path : '/api/auth',
credentials: 'same-origin',
lastSync: 0,
session: null,
fetchSession: async () => void 0,
}
}
static getInstance(): AuthConfigManager {
@ -47,49 +55,184 @@ class AuthConfigManager {
}
setConfig(userConfig: Partial<AuthClientConfig>): void {
this._config = { ...this._config, ...userConfig }
this.config = { ...this.config, ...userConfig }
}
getConfig(): AuthClientConfig {
return this._config
return this.config
}
initializeConfig(hasInitialSession: boolean): void {
this.config.lastSync = hasInitialSession ? now() : 0
}
}
export const authConfigManager = AuthConfigManager.getInstance()
function broadcast() {
if (typeof BroadcastChannel !== 'undefined') {
return new BroadcastChannel('auth-js')
}
return {
postMessage: () => {},
addEventListener: () => {},
removeEventListener: () => {},
}
}
export const SessionContext = React.createContext<SessionContextValue | undefined>(undefined)
// TODO:
const logger: LoggerInstance = {
debug: console.debug,
error: console.error,
warn: console.warn,
}
function useInitializeSession(hasInitialSession: boolean, initialSession: Session | null) {
const authConfig = authConfigManager.getConfig()
const [session, setSession] = React.useState<Session | null>(initialSession)
const [loading, setLoading] = React.useState(!hasInitialSession)
export type UpdateSession = (data?: any) => Promise<Session | null>
useEffect(() => {
authConfig.fetchSession = async ({ event } = {}) => {
try {
const isStorageEvent = event === 'storage'
export type SessionContextValue<R extends boolean = false> = R extends true
?
| { update: UpdateSession; data: Session; status: 'authenticated' }
| { update: UpdateSession; data: null; status: 'loading' }
:
| { update: UpdateSession; data: Session; status: 'authenticated' }
| {
update: UpdateSession
data: null
status: 'unauthenticated' | 'loading'
if (isStorageEvent || !authConfig.session) {
authConfig.lastSync = now()
authConfig.session = await getSession()
setSession(authConfig.session)
return
}
export const SessionContext = React.createContext?.<SessionContextValue | undefined>(undefined)
if (!event || !authConfig.session || now() < authConfig.lastSync) {
return
}
authConfig.lastSync = now()
authConfig.session = await getSession()
setSession(authConfig.session)
} catch (error) {
logger.error(new ClientSessionError((error as Error).message, error as any))
} finally {
setLoading(false)
}
}
authConfig.fetchSession()
return () => {
authConfig.lastSync = 0
authConfig.session = null
authConfig.fetchSession = async () => void 0
}
}, [])
return { session, setSession, loading, setLoading }
}
function useVisibilityChangeEventListener(
authConfig: AuthClientConfig,
refetchOnWindowFocus: boolean
) {
useEffect(() => {
const abortController = new AbortController()
const handleVisibilityChange = () => {
if (refetchOnWindowFocus && document.visibilityState === 'visible') {
authConfig.fetchSession({ event: 'visibilitychange' })
}
}
document.addEventListener('visibilitychange', handleVisibilityChange, {
signal: abortController.signal,
})
return () => abortController.abort()
}, [refetchOnWindowFocus])
}
function useRefetchInterval(
authConfig: AuthClientConfig,
refetchInterval?: number,
shouldRefetch?: boolean
) {
useEffect(() => {
if (refetchInterval && shouldRefetch) {
const intervalId = setInterval(() => {
if (authConfig.session) {
authConfig.fetchSession({ event: 'poll' })
}
}, refetchInterval * 1000)
return () => clearInterval(intervalId)
}
}, [refetchInterval, shouldRefetch])
}
export async function getSession(params?: GetSessionParams) {
const { baseUrl, basePath, credentials } = authConfigManager.getConfig()
const session = await fetchData<Session>(
'session',
{
baseUrl,
basePath,
credentials,
},
logger,
params
)
return session
}
export async function getCsrfToken() {
const { baseUrl, basePath, credentials } = authConfigManager.getConfig()
const response = await fetchData<{ csrfToken: string }>(
'csrf',
{
baseUrl,
basePath,
credentials,
},
logger
)
return response?.csrfToken ?? ''
}
export function SessionProvider(props: SessionProviderProps) {
if (!SessionContext) {
throw new Error('React Context is unavailable in Server Components')
}
const { children, refetchInterval, refetchWhenOffline = true } = props
const authConfig = authConfigManager.getConfig()
const hasInitialSession = !!props.session
authConfigManager.initializeConfig(hasInitialSession)
const { session, setSession, loading, setLoading } = useInitializeSession(
hasInitialSession,
props.session ?? null
)
useVisibilityChangeEventListener(authConfig, props.refetchOnWindowFocus ?? true)
const isOnline = useOnline()
const shouldRefetch = refetchWhenOffline || isOnline
useRefetchInterval(authConfig, refetchInterval, shouldRefetch)
const contextValue: SessionContextValue = useMemo(
() =>
({
data: session,
status: loading ? 'loading' : session ? 'authenticated' : 'unauthenticated',
update: async (data) => {
if (loading || !session) {
return
}
setLoading(true)
const updatedSession = await fetchData<Session>(
'session',
authConfig,
logger,
data ? { body: { csrfToken: await getCsrfToken(), data } } : undefined
)
setLoading(false)
if (updatedSession) {
setSession(updatedSession)
}
return updatedSession
},
} as SessionContextValue),
[session, loading, setSession]
)
return <SessionContext.Provider value={contextValue}>{children}</SessionContext.Provider>
}
export function useSession<R extends boolean>(
options?: UseSessionOptions<R>
@ -97,17 +240,18 @@ export function useSession<R extends boolean>(
if (!SessionContext) {
throw new Error('React Context is unavailable in Server Components')
}
const __AUTHJS: AuthClientConfig = authConfigManager.getConfig()
// @ts-expect-error Satisfy TS if branch on line below
const value: SessionContextValue<R> = React.useContext(SessionContext)
const config = authConfigManager.getConfig()
const session = useContext(SessionContext)
const { required, onUnauthenticated } = options ?? {}
const requiredAndNotLoading = required && value.status === 'unauthenticated'
const requiredAndNotLoading = required && session?.status === 'unauthenticated'
React.useEffect(() => {
useEffect(() => {
if (requiredAndNotLoading) {
const url = `${__AUTHJS.baseUrl}${__AUTHJS.basePath}/signin?${new URLSearchParams({
const url = `${config.baseUrl}${config.basePath}/signin?${new URLSearchParams({
error: 'SessionRequired',
callbackUrl: window.location.href,
})}`
@ -121,39 +265,13 @@ export function useSession<R extends boolean>(
if (requiredAndNotLoading) {
return {
data: value.data,
update: value.update,
data: session?.data,
update: session?.update,
status: 'loading',
}
}
return value
}
export interface GetSessionParams {
event?: 'storage' | 'timer' | 'hidden' | string
triggerEvent?: boolean
broadcast?: boolean
}
export async function getSession(params?: GetSessionParams) {
const session = await fetchData<Session>('session', authConfigManager.getConfig(), logger, params)
if (params?.broadcast ?? true) {
broadcast().postMessage({
event: 'session',
data: { trigger: 'getSession' },
})
}
return session
}
export async function getCsrfToken() {
const response = await fetchData<{ csrfToken: string }>(
'csrf',
authConfigManager.getConfig(),
logger
)
return response?.csrfToken ?? ''
return session as SessionContextValue<R>
}
type ProvidersType = Record<LiteralUnion<BuiltInProviderType>, ClientSafeProvider>
@ -166,260 +284,99 @@ export async function signIn<P extends RedirectableProviderType | undefined = un
provider?: LiteralUnion<
P extends RedirectableProviderType ? P | BuiltInProviderType : BuiltInProviderType
>,
options?: SignInOptions,
authorizationParams?: SignInAuthorizationParams
options: SignInOptions = {},
authorizationParams: SignInAuthorizationParams = {}
): Promise<P extends RedirectableProviderType ? SignInResponse | undefined : undefined> {
const { callbackUrl = window.location.href, redirect = true } = options ?? {}
const { callbackUrl = window.location.href, redirect = true, ...opts } = options
const __AUTHJS: AuthClientConfig = authConfigManager.getConfig()
const config = authConfigManager.getConfig()
const href = `${__AUTHJS.baseUrl}${__AUTHJS.basePath}`
const href = `${config.baseUrl}${config.basePath}`
const providers = await getProviders()
if (!providers) {
window.location.href = `${href}/error`
return
}
if (!provider || !(provider in providers)) {
window.location.href = `${href}/signin?${new URLSearchParams({
callbackUrl,
})}`
window.location.href = `${href}/signin?${new URLSearchParams({ callbackUrl })}`
return
}
const isCredentials = providers[provider].type === 'credentials'
const isEmail = providers[provider].type === 'email'
const isSupportingReturn = isCredentials || isEmail
const signInUrl = `${href}/${isCredentials ? 'callback' : 'signin'}/${provider}`
const csrfToken = await getCsrfToken()
const res = await fetch(`${signInUrl}?${new URLSearchParams(authorizationParams)}`, {
method: 'post',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Return-Redirect': '1',
},
// @ts-expect-error TODO: Fix this
body: new URLSearchParams({ ...options, csrfToken, callbackUrl }),
credentials: __AUTHJS.credentials,
body: new URLSearchParams({ ...opts, csrfToken, callbackUrl }),
credentials: config.credentials,
})
const data = await res.json()
const data = (await res.json()) as { url: string }
// TODO: Do not redirect for Credentials and Email providers by default in next major
if (redirect || !isSupportingReturn) {
const url = (data as any).url ?? callbackUrl
if (redirect) {
const url = data.url ?? callbackUrl
window.location.href = url
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) {
window.location.reload()
}
return
}
const error = new URL((data as any).url).searchParams.get('error')
const error = new URL(data.url).searchParams.get('error')
if (res.ok) {
await __AUTHJS._getSession({ event: 'storage' })
await config.fetchSession?.({ event: 'storage' })
}
return {
error,
status: res.status,
ok: res.ok,
url: error ? null : (data as any).url,
} as any
url: error ? null : data.url,
} as P extends RedirectableProviderType ? SignInResponse : undefined
}
/**
* Initiate a signout, by destroying the current session.
* Handles CSRF protection.
*/
export async function signOut<R extends boolean = true>(
options?: SignOutParams<R>
): Promise<R extends true ? undefined : SignOutResponse> {
const { callbackUrl = window.location.href } = options ?? {}
const __AUTHJS: AuthClientConfig = authConfigManager.getConfig()
const href = `${__AUTHJS.baseUrl}${__AUTHJS.basePath}`
const { callbackUrl = window.location.href, redirect = true } = options ?? {}
const config = authConfigManager.getConfig()
const csrfToken = await getCsrfToken()
const res = await fetch(`${href}/signout`, {
method: 'post',
const res = await fetch(`${config.baseUrl}${config.basePath}/signout`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Return-Redirect': '1',
},
body: new URLSearchParams({ csrfToken, callbackUrl }),
credentials: __AUTHJS.credentials,
credentials: config.credentials,
})
const data = await res.json()
broadcast().postMessage({ event: 'session', data: { trigger: 'signout' } })
const data = (await res.json()) as { url: string }
if (options?.redirect ?? true) {
const url = (data as any).url ?? callbackUrl
if (redirect) {
const url = data.url ?? callbackUrl
window.location.href = url
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) {
window.location.reload()
}
// @ts-expect-error TODO: Fix this
return
return undefined as R extends true ? undefined : SignOutResponse
}
await __AUTHJS._getSession({ event: 'storage' })
await config.fetchSession?.({ event: 'storage' })
return data as any
}
export function SessionProvider(props: SessionProviderProps) {
if (!SessionContext) {
throw new Error('React Context is unavailable in Server Components')
}
const { children, refetchInterval, refetchWhenOffline } = props
const __AUTHJS: AuthClientConfig = authConfigManager.getConfig()
const hasInitialSession = props.session !== undefined
__AUTHJS._lastSync = hasInitialSession ? now() : 0
const [session, setSession] = React.useState(() => {
if (hasInitialSession) {
__AUTHJS._session = props.session
}
return props.session
})
const [loading, setLoading] = React.useState(!hasInitialSession)
React.useEffect(() => {
__AUTHJS._getSession = async ({ event } = {}) => {
try {
const storageEvent = event === 'storage'
if (storageEvent || __AUTHJS._session === undefined) {
__AUTHJS._lastSync = now()
__AUTHJS._session = await getSession({
broadcast: !storageEvent,
})
setSession(__AUTHJS._session)
return
}
if (
// If there is no time defined for when a session should be considered
// stale, then it's okay to use the value we have until an event is
// triggered which updates it
!event ||
// If the client doesn't have a session then we don't need to call
// the server to check if it does (if they have signed in via another
// tab or window that will come through as a "stroage" event
// event anyway)
__AUTHJS._session === null ||
// Bail out early if the client session is not stale yet
now() < __AUTHJS._lastSync
) {
return
}
// An event or session staleness occurred, update the client session.
__AUTHJS._lastSync = now()
__AUTHJS._session = await getSession()
setSession(__AUTHJS._session)
} catch (error) {
logger.error(new ClientSessionError((error as Error).message, error as any))
} finally {
setLoading(false)
}
}
__AUTHJS._getSession()
return () => {
__AUTHJS._lastSync = 0
__AUTHJS._session = undefined
__AUTHJS._getSession = () => {}
}
}, [])
React.useEffect(() => {
const handle = () => __AUTHJS._getSession({ event: 'storage' })
// Listen for storage events and update session if event fired from
// another window (but suppress firing another event to avoid a loop)
// Fetch new session data but tell it to not to fire another event to
// avoid an infinite loop.
// Note: We could pass session data through and do something like
// `setData(message.data)` but that can cause problems depending
// on how the session object is being used in the client; it is
// more robust to have each window/tab fetch it's own copy of the
// session object rather than share it across instances.
broadcast().addEventListener('message', handle)
return () => broadcast().removeEventListener('message', handle)
}, [])
React.useEffect(() => {
const { refetchOnWindowFocus = true } = props
// Listen for when the page is visible, if the user switches tabs
// and makes our tab visible again, re-fetch the session, but only if
// this feature is not disabled.
const visibilityHandler = () => {
if (refetchOnWindowFocus && document.visibilityState === 'visible') {
__AUTHJS._getSession({ event: 'visibilitychange' })
}
}
document.addEventListener('visibilitychange', visibilityHandler, false)
return () => document.removeEventListener('visibilitychange', visibilityHandler, false)
}, [props.refetchOnWindowFocus])
const isOnline = useOnline()
// TODO: Flip this behavior in next major version
const shouldRefetch = refetchWhenOffline !== false || isOnline
React.useEffect(() => {
if (refetchInterval && shouldRefetch) {
const refetchIntervalTimer = setInterval(() => {
if (__AUTHJS._session) {
__AUTHJS._getSession({ event: 'poll' })
}
}, refetchInterval * 1000)
return () => clearInterval(refetchIntervalTimer)
}
}, [refetchInterval, shouldRefetch])
const value: any = React.useMemo(
() => ({
data: session,
status: loading ? 'loading' : session ? 'authenticated' : 'unauthenticated',
async update(data: any) {
if (loading || !session) {
return
}
setLoading(true)
const newSession = await fetchData<Session>(
'session',
__AUTHJS,
logger,
typeof data === 'undefined'
? undefined
: { body: { csrfToken: await getCsrfToken(), data } }
)
setLoading(false)
if (newSession) {
setSession(newSession)
broadcast().postMessage({
event: 'session',
data: { trigger: 'getSession' },
})
}
return newSession
},
}),
[session, loading]
)
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
return data as R extends true ? undefined : SignOutResponse
}

View File

@ -186,9 +186,14 @@ describe('Credentials Provider', () => {
headers,
})
expect(res.status).toBe(200)
const obj = await res.json()
expect(obj['token']['name']).toBe(user.name)
expect(obj['token']['email']).toBe(user.email)
const obj = await res.json<{
token: {
name: string
email: string
}
}>()
expect(obj.token.name).toBe(user.name)
expect(obj.token.email).toBe(user.email)
})
it('Should respect x-forwarded-proto and x-forwarded-host', async () => {
@ -198,7 +203,7 @@ describe('Credentials Provider', () => {
const res = await app.request('http://localhost/api/auth/signin', {
headers,
})
let html = await res.text()
const html = await res.text()
expect(html).toContain('action="https://example.com/api/auth/callback/credentials"')
})
})