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

454 lines
12 KiB
TypeScript
Raw Normal View History

import * as React from 'react'
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,
WindowProps,
AuthState,
} from './client'
import type { LoggerInstance, Session } from '@auth/core/types'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { BuiltInProviderType, RedirectableProviderType } from '@auth/core/providers'
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 }
}