honojs-middleware/packages/auth-js/src/react.tsx

452 lines
12 KiB
TypeScript

import type { BuiltInProviderType, RedirectableProviderType } from '@auth/core/providers'
import type { LoggerInstance, Session } from '@auth/core/types'
import * as React from 'react'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { ClientSessionError, fetchData, now, parseUrl, useOnline } from './client'
import type {
WindowProps,
AuthState,
AuthClientConfig,
SessionContextValue,
SessionProviderProps,
GetSessionParams,
UseSessionOptions,
LiteralUnion,
SignInOptions,
SignInAuthorizationParams,
SignInResponse,
ClientSafeProvider,
SignOutParams,
SignOutResponse,
} from './client'
const logger: LoggerInstance = {
debug: console.debug,
error: console.error,
warn: console.warn,
}
class AuthConfigManager {
private static instance: AuthConfigManager | null = null
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 {
if (!AuthConfigManager.instance) {
AuthConfigManager.instance = new AuthConfigManager()
}
return AuthConfigManager.instance
}
setConfig(userConfig: Partial<AuthClientConfig>): void {
this.config = { ...this.config, ...userConfig }
}
getConfig(): AuthClientConfig {
return this.config
}
initializeConfig(hasInitialSession: boolean): void {
this.config.lastSync = hasInitialSession ? now() : 0
}
}
export const authConfigManager = AuthConfigManager.getInstance()
export const SessionContext = React.createContext<SessionContextValue | undefined>(undefined)
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)
useEffect(() => {
authConfig.fetchSession = async ({ event } = {}) => {
try {
const isStorageEvent = event === 'storage'
if (isStorageEvent || !authConfig.session) {
authConfig.lastSync = now()
authConfig.session = await getSession()
setSession(authConfig.session)
return
}
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>
): SessionContextValue<R> {
if (!SessionContext) {
throw new Error('React Context is unavailable in Server Components')
}
const config = authConfigManager.getConfig()
const session = useContext(SessionContext)
const { required, onUnauthenticated } = options ?? {}
const requiredAndNotLoading = required && session?.status === 'unauthenticated'
useEffect(() => {
if (requiredAndNotLoading) {
const url = `${config.baseUrl}${config.basePath}/signin?${new URLSearchParams({
error: 'SessionRequired',
callbackUrl: window.location.href,
})}`
if (onUnauthenticated) {
onUnauthenticated()
} else {
window.location.href = url
}
}
}, [requiredAndNotLoading, onUnauthenticated])
if (requiredAndNotLoading) {
return {
data: session?.data,
update: session?.update,
status: 'loading',
}
}
return session as SessionContextValue<R>
}
type ProvidersType = Record<LiteralUnion<BuiltInProviderType>, ClientSafeProvider>
export async function getProviders() {
return fetchData<ProvidersType>('providers', authConfigManager.getConfig(), logger)
}
export async function signIn<P extends RedirectableProviderType | undefined = undefined>(
provider?: LiteralUnion<
P extends RedirectableProviderType ? P | BuiltInProviderType : BuiltInProviderType
>,
options: SignInOptions = {},
authorizationParams: SignInAuthorizationParams = {}
): Promise<P extends RedirectableProviderType ? SignInResponse | undefined : undefined> {
const { callbackUrl = window.location.href, redirect = true, ...opts } = options
const config = authConfigManager.getConfig()
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 })}`
return
}
const isCredentials = providers[provider].type === 'credentials'
const isEmail = providers[provider].type === 'email'
const signInUrl = `${href}/${isCredentials ? 'callback' : 'signin'}/${provider}`
const csrfToken = await getCsrfToken()
const res = await fetch(`${signInUrl}?${new URLSearchParams(authorizationParams)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Return-Redirect': '1',
},
body: new URLSearchParams({ ...opts, csrfToken, callbackUrl }),
credentials: config.credentials,
})
const data = (await res.json()) as { url: string }
if (redirect) {
const url = data.url ?? callbackUrl
window.location.href = url
if (url.includes('#')) {
window.location.reload()
}
return
}
const error = new URL(data.url).searchParams.get('error')
if (res.ok) {
await config.fetchSession?.({ event: 'storage' })
}
return {
error,
status: res.status,
ok: res.ok,
url: error ? null : data.url,
} as P extends RedirectableProviderType ? SignInResponse : undefined
}
export async function signOut<R extends boolean = true>(
options?: SignOutParams<R>
): Promise<R extends true ? undefined : SignOutResponse> {
const { callbackUrl = window.location.href, redirect = true } = options ?? {}
const config = authConfigManager.getConfig()
const csrfToken = await getCsrfToken()
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: config.credentials,
})
const data = (await res.json()) as { url: string }
if (redirect) {
const url = data.url ?? callbackUrl
window.location.href = url
if (url.includes('#')) {
window.location.reload()
}
return undefined as R extends true ? undefined : SignOutResponse
}
await config.fetchSession?.({ event: 'storage' })
return data as R extends true ? undefined : SignOutResponse
}
const createPopup = ({ url, title, height, width }: WindowProps) => {
const left = window.screenX + (window.outerWidth - width) / 2
const top = window.screenY + (window.outerHeight - height) / 2.5
const externalPopup = window.open(
url,
title,
`width=${width},height=${height},left=${left},top=${top}`
)
return externalPopup
}
interface PopupLoginOptions extends Partial<Omit<WindowProps, 'url'>> {
onSuccess?: () => void
callbackUrl?: string
}
export const useOauthPopupLogin = (
provider: Parameters<typeof signIn>[0],
options: PopupLoginOptions = {}
) => {
const { width = 500, height = 500, title = 'Signin', onSuccess, callbackUrl = '/' } = options
const [externalWindow, setExternalWindow] = useState<Window | null>()
const [state, setState] = useState<AuthState>({ status: 'loading' })
const popUpSignin = useCallback(async () => {
const res = await signIn(provider, {
redirect: false,
callbackUrl,
})
if (res?.error) {
setState({ status: 'errored', error: res.error })
return
}
setExternalWindow(
createPopup({
url: res?.url as string,
title,
width,
height,
})
)
}, [])
useEffect(() => {
const handleMessage = (event: MessageEvent<AuthState>) => {
if (event.origin !== window.location.origin) {
return
}
if (event.data.status) {
setState(event.data)
if (event.data.status === 'success') {
onSuccess?.()
}
externalWindow?.close()
}
}
window.addEventListener('message', handleMessage)
return () => {
window.removeEventListener('message', handleMessage)
externalWindow?.close()
}
}, [externalWindow])
return { popUpSignin, ...state }
}