From c19b51baaf396647f2d6b021e38f083768328b74 Mon Sep 17 00:00:00 2001 From: Divyam <47589864+divyam234@users.noreply.github.com> Date: Mon, 21 Oct 2024 03:53:51 +0530 Subject: [PATCH] refactor(auth-js): session provider (#775) * refactor: session provider * format --- .changeset/rotten-tips-camp.md | 5 + packages/auth-js/README.md | 106 +++--- packages/auth-js/src/client.ts | 70 ++-- packages/auth-js/src/index.ts | 45 ++- packages/auth-js/src/react.tsx | 531 +++++++++++++--------------- packages/auth-js/test/index.test.ts | 13 +- 6 files changed, 362 insertions(+), 408 deletions(-) create mode 100644 .changeset/rotten-tips-camp.md diff --git a/.changeset/rotten-tips-camp.md b/.changeset/rotten-tips-camp.md new file mode 100644 index 000000000..542427d50 --- /dev/null +++ b/.changeset/rotten-tips-camp.md @@ -0,0 +1,5 @@ +--- +'@hono/auth-js': patch +--- + +refactor session provider diff --git a/packages/auth-js/README.md b/packages/auth-js/README.md index bfac2668e..d86661bf4 100644 --- a/packages/auth-js/README.md +++ b/packages/auth-js/README.md @@ -16,78 +16,72 @@ 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( + '*', + initAuthConfig((c) => ({ + secret: c.env.AUTH_SECRET, + providers: [ + GitHub({ + clientId: c.env.GITHUB_ID, + clientSecret: c.env.GITHUB_SECRET, + }), + ], + })) +) -app.use("/api/auth/*", authHandler()) +app.use('/api/auth/*', authHandler()) app.use('/api/*', verifyAuth()) app.get('/api/protected', (c) => { - const auth = c.get("authUser") + const auth = c.get('authUser') return c.json(auth) }) -function getAuthConfig(c: Context): AuthConfig { - return { - secret: c.env.AUTH_SECRET, - providers: [ - GitHub({ - clientId: c.env.GITHUB_ID, - clientSecret: c.env.GITHUB_SECRET - }), - ] - } -} - export default app ``` React component -```tsx -import { SessionProvider } from "@hono/auth-js/react" -export default function App() { +```tsx +import { SessionProvider, useSession } from '@hono/auth-js/react' +export default function App() { return ( - + ) } function Children() { const { data: session, status } = useSession() - return ( -
- I am {session?.user} -
- ) + return
I am {session?.user}
} ``` + 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, authConfigManager, 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 ( @@ -97,45 +91,27 @@ export default function App() { function Children() { const { data: session, status } = useSession() - return ( -
- I am {session?.user} -
- ) + return
I am {session?.user}
} ``` -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 +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 } +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 diff --git a/packages/auth-js/src/client.ts b/packages/auth-js/src/client.ts index 36e979cfd..d9afc032a 100644 --- a/packages/auth-js/src/client.ts +++ b/packages/auth-js/src/client.ts @@ -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 } export interface UseSessionOptions { @@ -32,13 +37,7 @@ export interface ClientSafeProvider { } export interface SignInOptions extends Record { - /** - * 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 { - /** [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 + +export type SessionContextValue = 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( path: string, - config: AuthClientConfig, + config: { + baseUrl: string + basePath: string + credentials: RequestCredentials + }, logger: LoggerInstance, req: any = {} ): Promise { @@ -111,20 +123,22 @@ export async function fetchData( } 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, - }; + } } diff --git a/packages/auth-js/src/index.ts b/packages/auth-js/src/index.ts index a532de4eb..395a161ca 100644 --- a/packages/auth-js/src/index.ts +++ b/packages/auth-js/src/index.ts @@ -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 { @@ -114,7 +112,7 @@ export async function getAuthUser(c: Context): Promise { 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() } diff --git a/packages/auth-js/src/react.tsx b/packages/auth-js/src/react.tsx index b2b3a6100..612015b6f 100644 --- a/packages/auth-js/src/react.tsx +++ b/packages/auth-js/src/react.tsx @@ -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): 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(undefined) + +function useInitializeSession(hasInitialSession: boolean, initialSession: Session | null) { + const authConfig = authConfigManager.getConfig() + const [session, setSession] = React.useState(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 } } -// TODO: -const logger: LoggerInstance = { - debug: console.debug, - error: console.error, - warn: console.warn, +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]) } -export type UpdateSession = (data?: any) => Promise - -export type SessionContextValue = 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' +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', + { + 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 const SessionContext = React.createContext?.(undefined) +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', + authConfig, + logger, + data ? { body: { csrfToken: await getCsrfToken(), data } } : undefined + ) + setLoading(false) + if (updatedSession) { + setSession(updatedSession) + } + return updatedSession + }, + } as SessionContextValue), + [session, loading, setSession] + ) + + return {children} +} export function useSession( options?: UseSessionOptions @@ -97,17 +240,18 @@ export function useSession( 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 = 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( 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', 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 } type ProvidersType = Record, ClientSafeProvider> @@ -166,260 +284,99 @@ export async function signIn

, - options?: SignInOptions, - authorizationParams?: SignInAuthorizationParams + options: SignInOptions = {}, + authorizationParams: SignInAuthorizationParams = {} ): Promise

{ - 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( options?: SignOutParams ): Promise { - 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 - } - await __AUTHJS._getSession({ event: 'storage' }) - - return data as any -} - -export function SessionProvider(props: SessionProviderProps) { - if (!SessionContext) { - throw new Error('React Context is unavailable in Server Components') + return undefined as R extends true ? undefined : SignOutResponse } - 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', - __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] - ) + await config.fetchSession?.({ event: 'storage' }) - return {children} + return data as R extends true ? undefined : SignOutResponse } diff --git a/packages/auth-js/test/index.test.ts b/packages/auth-js/test/index.test.ts index 1199bead8..96670145d 100644 --- a/packages/auth-js/test/index.test.ts +++ b/packages/auth-js/test/index.test.ts @@ -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"') }) })