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 ```plain
AUTH_SECRET=#required AUTH_SECRET=#required
AUTH_URL=#optional AUTH_URL=https://example.com/api/auth
``` ```
## How to Use ## How to Use
```ts ```ts
import { Hono, Context } from 'hono' import { Hono } from 'hono'
import { authHandler, initAuthConfig, verifyAuth, type AuthConfig } from "@hono/auth-js" import { authHandler, initAuthConfig, verifyAuth } from '@hono/auth-js'
import GitHub from "@auth/core/providers/github" import GitHub from '@auth/core/providers/github'
const app = new Hono() const app = new Hono()
app.use("*", initAuthConfig(getAuthConfig)) app.use(
'*',
app.use("/api/auth/*", authHandler()) initAuthConfig((c) => ({
app.use('/api/*', verifyAuth())
app.get('/api/protected', (c) => {
const auth = c.get("authUser")
return c.json(auth)
})
function getAuthConfig(c: Context): AuthConfig {
return {
secret: c.env.AUTH_SECRET, secret: c.env.AUTH_SECRET,
providers: [ providers: [
GitHub({ GitHub({
clientId: c.env.GITHUB_ID, 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 export default app
``` ```
React component 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 ```tsx
import {SessionProvider,authConfigManager,useSession } from "@hono/auth-js/react" import { SessionProvider, useSession } from '@hono/auth-js/react'
authConfigManager.setConfig({ export default function App() {
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() {
return ( return (
<SessionProvider> <SessionProvider>
<Children /> <Children />
@ -97,45 +68,50 @@ export default function App() {
function Children() { function Children() {
const { data: session, status } = useSession() 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 ( return (
<div > <SessionProvider>
I am {session?.user} <Children />
</div> </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,
})
)
```
function Children() {
SessionProvider is not needed with react query.This wrapper is enough const { data: session, status } = useSession()
return <div>I am {session?.user}</div>
```ts }
const useSession = ()=>{ ```
const { data ,status } = useQuery({
queryKey: ["session"], SessionProvider is not needed with react query.Use useQuery hook to fetch session data.
queryFn: async () => {
const res = await fetch("/api/auth/session") ```ts
return res.json(); const useSession = () => {
}, const { data, status } = useQuery({
staleTime: 5 * (60 * 1000), queryKey: ['session'],
gcTime: 10 * (60 * 1000), queryFn: async () => {
refetchOnWindowFocus: true, const res = await fetch('/api/auth/session')
}) return res.json()
return { session:data, status } },
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 Working example repo https://github.com/divyam234/next-auth-hono-react

View File

@ -1,19 +1,24 @@
import { AuthError } from '@auth/core/errors' import { AuthError } from '@auth/core/errors'
import type { BuiltInProviderType, ProviderType } from '@auth/core/providers' import type { BuiltInProviderType, ProviderType } from '@auth/core/providers'
import type { LoggerInstance, Session } from '@auth/core/types' import type { LoggerInstance, Session } from '@auth/core/types'
import * as React from 'react' import { useEffect, useState } from 'react'
class ClientFetchError extends AuthError {} class ClientFetchError extends AuthError {}
export class ClientSessionError extends AuthError {} export class ClientSessionError extends AuthError {}
export interface GetSessionParams {
event?: 'storage' | 'timer' | 'hidden' | string
triggerEvent?: boolean
}
export interface AuthClientConfig { export interface AuthClientConfig {
baseUrl: string baseUrl: string
basePath: string basePath: string
credentials?: RequestCredentials credentials: RequestCredentials
_session?: Session | null | undefined lastSync: number
_lastSync: number session: Session | null
_getSession: (...args: any[]) => any fetchSession: (params?: GetSessionParams) => Promise<void>
} }
export interface UseSessionOptions<R extends boolean> { export interface UseSessionOptions<R extends boolean> {
@ -32,13 +37,7 @@ export interface ClientSafeProvider {
} }
export interface SignInOptions extends Record<string, unknown> { 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 callbackUrl?: string
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option) */
redirect?: boolean redirect?: boolean
} }
@ -60,26 +59,39 @@ export interface SignOutResponse {
} }
export interface SignOutParams<R extends boolean = true> { export interface SignOutParams<R extends boolean = true> {
/** [Documentation](https://next-auth.js.org/getting-started/client#specifying-a-callbackurl-1) */
callbackUrl?: string callbackUrl?: string
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1 */
redirect?: R redirect?: R
} }
export interface SessionProviderProps { export interface SessionProviderProps {
children: React.ReactNode children: React.ReactNode
session?: Session | null session?: Session | null
baseUrl?: string
basePath?: string
refetchInterval?: number refetchInterval?: number
refetchOnWindowFocus?: boolean refetchOnWindowFocus?: boolean
refetchWhenOffline?: false 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>( export async function fetchData<T = any>(
path: string, path: string,
config: AuthClientConfig, config: {
baseUrl: string
basePath: string
credentials: RequestCredentials
},
logger: LoggerInstance, logger: LoggerInstance,
req: any = {} req: any = {}
): Promise<T | null> { ): Promise<T | null> {
@ -111,20 +123,22 @@ export async function fetchData<T = any>(
} }
export function useOnline() { export function useOnline() {
const [isOnline, setIsOnline] = React.useState( const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : false typeof navigator !== 'undefined' ? navigator.onLine : false
) )
React.useEffect(() => { useEffect(() => {
const abortController = new AbortController()
const { signal } = abortController
const setOnline = () => setIsOnline(true) const setOnline = () => setIsOnline(true)
const setOffline = () => setIsOnline(false) const setOffline = () => setIsOnline(false)
window.addEventListener('online', setOnline) window.addEventListener('online', setOnline, { signal })
window.addEventListener('offline', setOffline) window.addEventListener('offline', setOffline, { signal })
return () => { return () => {
window.removeEventListener('online', setOnline) abortController.abort()
window.removeEventListener('offline', setOffline)
} }
}, []) }, [])
@ -136,11 +150,11 @@ export function now() {
} }
export function parseUrl(url?: string) { export function parseUrl(url?: string) {
const defaultUrl = 'http://localhost:3000/api/auth'; const defaultUrl = 'http://localhost:3000/api/auth'
const parsedUrl = new URL(url?.startsWith('http') ? url : `https://${url}` || defaultUrl); const parsedUrl = new URL(url?.startsWith('http') ? url : `https://${url}` || defaultUrl)
const path = parsedUrl.pathname === '/' ? '/api/auth' : parsedUrl.pathname.replace(/\/$/, ''); const path = parsedUrl.pathname === '/' ? '/api/auth' : parsedUrl.pathname.replace(/\/$/, '')
const base = `${parsedUrl.origin}${path}`; const base = `${parsedUrl.origin}${path}`
return { return {
origin: parsedUrl.origin, origin: parsedUrl.origin,
@ -148,5 +162,5 @@ export function parseUrl(url?: string) {
path, path,
base, base,
toString: () => 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), headers: headers ?? new Headers(request.headers),
body: body:
request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.blob(), request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.blob(),
// @ts-ignore: TS2353
referrer: 'referrer' in request ? (request.referrer as string) : undefined, referrer: 'referrer' in request ? (request.referrer as string) : undefined,
// deno-lint-ignore no-explicit-any referrerPolicy: request.referrerPolicy,
referrerPolicy: request.referrerPolicy as any,
mode: request.mode, mode: request.mode,
credentials: request.credentials, credentials: request.credentials,
// @ts-ignore: TS2353
cache: request.cache, cache: request.cache,
redirect: request.redirect, redirect: request.redirect,
integrity: request.integrity, integrity: request.integrity,
@ -66,25 +63,26 @@ export async function reqWithEnvUrl(req: Request, authUrl?: string) {
const reqUrlObj = new URL(req.url) const reqUrlObj = new URL(req.url)
const authUrlObj = new URL(authUrl) const authUrlObj = new URL(authUrl)
const props = ['hostname', 'protocol', 'port', 'password', 'username'] as const const props = ['hostname', 'protocol', 'port', 'password', 'username'] as const
props.forEach((prop) => (reqUrlObj[prop] = authUrlObj[prop])) for (const prop of props) {
return cloneRequest(reqUrlObj.href, req) if (authUrlObj[prop]) reqUrlObj[prop] = authUrlObj[prop]
} 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)
} }
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> { 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 const session = (await response.json()) as Session | null
return session && session.user ? authUser : null return session?.user ? authUser : null
} }
export function verifyAuth(): MiddlewareHandler { export function verifyAuth(): MiddlewareHandler {
@ -126,9 +124,8 @@ export function verifyAuth(): MiddlewareHandler {
status: 401, status: 401,
}) })
throw new HTTPException(401, { res }) throw new HTTPException(401, { res })
} else {
c.set('authUser', authUser)
} }
c.set('authUser', authUser)
await next() 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 * as React from 'react'
import { ClientSessionError, fetchData, now, parseUrl, useOnline } from './client' import {
type AuthClientConfig,
import type { ClientSessionError,
AuthClientConfig, fetchData,
ClientSafeProvider, now,
LiteralUnion, parseUrl,
SessionProviderProps, useOnline,
SignInAuthorizationParams, type SessionContextValue,
SignInOptions, type SessionProviderProps,
SignInResponse, type GetSessionParams,
SignOutParams, type UseSessionOptions,
SignOutResponse, type LiteralUnion,
UseSessionOptions, type SignInOptions,
type SignInAuthorizationParams,
type SignInResponse,
type ClientSafeProvider,
type SignOutParams,
type SignOutResponse,
} from './client' } 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? const logger: LoggerInstance = {
export type { debug: console.debug,
LiteralUnion, error: console.error,
SignInOptions, warn: console.warn,
SignInAuthorizationParams,
SignOutParams,
SignInResponse,
} }
export { SessionProviderProps }
class AuthConfigManager { class AuthConfigManager {
private static instance: AuthConfigManager | null = null private static instance: AuthConfigManager | null = null
_config: AuthClientConfig = { private config: AuthClientConfig
baseUrl: typeof window !== 'undefined' ? parseUrl(window.location.origin).origin : '',
basePath: typeof window !== 'undefined' ? parseUrl(window.location.origin).path : '/api/auth', private constructor() {
credentials: 'same-origin', this.config = this.createDefaultConfig()
_lastSync: 0, }
_session: undefined,
_getSession: () => {}, 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 { static getInstance(): AuthConfigManager {
@ -47,49 +55,184 @@ class AuthConfigManager {
} }
setConfig(userConfig: Partial<AuthClientConfig>): void { setConfig(userConfig: Partial<AuthClientConfig>): void {
this._config = { ...this._config, ...userConfig } this.config = { ...this.config, ...userConfig }
} }
getConfig(): AuthClientConfig { getConfig(): AuthClientConfig {
return this._config return this.config
}
initializeConfig(hasInitialSession: boolean): void {
this.config.lastSync = hasInitialSession ? now() : 0
} }
} }
export const authConfigManager = AuthConfigManager.getInstance() export const authConfigManager = AuthConfigManager.getInstance()
function broadcast() { export const SessionContext = React.createContext<SessionContextValue | undefined>(undefined)
if (typeof BroadcastChannel !== 'undefined') {
return new BroadcastChannel('auth-js')
}
return {
postMessage: () => {},
addEventListener: () => {},
removeEventListener: () => {},
}
}
// TODO: function useInitializeSession(hasInitialSession: boolean, initialSession: Session | null) {
const logger: LoggerInstance = { const authConfig = authConfigManager.getConfig()
debug: console.debug, const [session, setSession] = React.useState<Session | null>(initialSession)
error: console.error, const [loading, setLoading] = React.useState(!hasInitialSession)
warn: console.warn,
}
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 if (isStorageEvent || !authConfig.session) {
? authConfig.lastSync = now()
| { update: UpdateSession; data: Session; status: 'authenticated' } authConfig.session = await getSession()
| { update: UpdateSession; data: null; status: 'loading' } setSession(authConfig.session)
: return
| { update: UpdateSession; data: Session; status: 'authenticated' }
| {
update: UpdateSession
data: null
status: 'unauthenticated' | 'loading'
} }
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>( export function useSession<R extends boolean>(
options?: UseSessionOptions<R> options?: UseSessionOptions<R>
@ -97,17 +240,18 @@ export function useSession<R extends boolean>(
if (!SessionContext) { if (!SessionContext) {
throw new Error('React Context is unavailable in Server Components') 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 config = authConfigManager.getConfig()
const value: SessionContextValue<R> = React.useContext(SessionContext)
const session = useContext(SessionContext)
const { required, onUnauthenticated } = options ?? {} const { required, onUnauthenticated } = options ?? {}
const requiredAndNotLoading = required && value.status === 'unauthenticated' const requiredAndNotLoading = required && session?.status === 'unauthenticated'
React.useEffect(() => { useEffect(() => {
if (requiredAndNotLoading) { if (requiredAndNotLoading) {
const url = `${__AUTHJS.baseUrl}${__AUTHJS.basePath}/signin?${new URLSearchParams({ const url = `${config.baseUrl}${config.basePath}/signin?${new URLSearchParams({
error: 'SessionRequired', error: 'SessionRequired',
callbackUrl: window.location.href, callbackUrl: window.location.href,
})}` })}`
@ -121,39 +265,13 @@ export function useSession<R extends boolean>(
if (requiredAndNotLoading) { if (requiredAndNotLoading) {
return { return {
data: value.data, data: session?.data,
update: value.update, update: session?.update,
status: 'loading', status: 'loading',
} }
} }
return value return session as SessionContextValue<R>
}
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 ?? ''
} }
type ProvidersType = Record<LiteralUnion<BuiltInProviderType>, ClientSafeProvider> type ProvidersType = Record<LiteralUnion<BuiltInProviderType>, ClientSafeProvider>
@ -166,260 +284,99 @@ export async function signIn<P extends RedirectableProviderType | undefined = un
provider?: LiteralUnion< provider?: LiteralUnion<
P extends RedirectableProviderType ? P | BuiltInProviderType : BuiltInProviderType P extends RedirectableProviderType ? P | BuiltInProviderType : BuiltInProviderType
>, >,
options?: SignInOptions, options: SignInOptions = {},
authorizationParams?: SignInAuthorizationParams authorizationParams: SignInAuthorizationParams = {}
): Promise<P extends RedirectableProviderType ? SignInResponse | undefined : undefined> { ): 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() const providers = await getProviders()
if (!providers) { if (!providers) {
window.location.href = `${href}/error` window.location.href = `${href}/error`
return return
} }
if (!provider || !(provider in providers)) { if (!provider || !(provider in providers)) {
window.location.href = `${href}/signin?${new URLSearchParams({ window.location.href = `${href}/signin?${new URLSearchParams({ callbackUrl })}`
callbackUrl,
})}`
return return
} }
const isCredentials = providers[provider].type === 'credentials' const isCredentials = providers[provider].type === 'credentials'
const isEmail = providers[provider].type === 'email' const isEmail = providers[provider].type === 'email'
const isSupportingReturn = isCredentials || isEmail
const signInUrl = `${href}/${isCredentials ? 'callback' : 'signin'}/${provider}` const signInUrl = `${href}/${isCredentials ? 'callback' : 'signin'}/${provider}`
const csrfToken = await getCsrfToken() const csrfToken = await getCsrfToken()
const res = await fetch(`${signInUrl}?${new URLSearchParams(authorizationParams)}`, { const res = await fetch(`${signInUrl}?${new URLSearchParams(authorizationParams)}`, {
method: 'post', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Return-Redirect': '1', 'X-Auth-Return-Redirect': '1',
}, },
// @ts-expect-error TODO: Fix this body: new URLSearchParams({ ...opts, csrfToken, callbackUrl }),
body: new URLSearchParams({ ...options, csrfToken, callbackUrl }), credentials: config.credentials,
credentials: __AUTHJS.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) {
if (redirect || !isSupportingReturn) { const url = data.url ?? callbackUrl
const url = (data as any).url ?? callbackUrl
window.location.href = url window.location.href = url
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) { if (url.includes('#')) {
window.location.reload() window.location.reload()
} }
return return
} }
const error = new URL((data as any).url).searchParams.get('error') const error = new URL(data.url).searchParams.get('error')
if (res.ok) { if (res.ok) {
await __AUTHJS._getSession({ event: 'storage' }) await config.fetchSession?.({ event: 'storage' })
} }
return { return {
error, error,
status: res.status, status: res.status,
ok: res.ok, ok: res.ok,
url: error ? null : (data as any).url, url: error ? null : data.url,
} as any } 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>( export async function signOut<R extends boolean = true>(
options?: SignOutParams<R> options?: SignOutParams<R>
): Promise<R extends true ? undefined : SignOutResponse> { ): Promise<R extends true ? undefined : SignOutResponse> {
const { callbackUrl = window.location.href } = options ?? {} const { callbackUrl = window.location.href, redirect = true } = options ?? {}
const __AUTHJS: AuthClientConfig = authConfigManager.getConfig() const config = authConfigManager.getConfig()
const href = `${__AUTHJS.baseUrl}${__AUTHJS.basePath}`
const csrfToken = await getCsrfToken() const csrfToken = await getCsrfToken()
const res = await fetch(`${href}/signout`, { const res = await fetch(`${config.baseUrl}${config.basePath}/signout`, {
method: 'post', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Return-Redirect': '1', 'X-Auth-Return-Redirect': '1',
}, },
body: new URLSearchParams({ csrfToken, callbackUrl }), 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) { if (redirect) {
const url = (data as any).url ?? callbackUrl const url = data.url ?? callbackUrl
window.location.href = url window.location.href = url
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) { if (url.includes('#')) {
window.location.reload() 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 return data as R extends true ? undefined : SignOutResponse
}
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>
} }

View File

@ -186,9 +186,14 @@ describe('Credentials Provider', () => {
headers, headers,
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const obj = await res.json() const obj = await res.json<{
expect(obj['token']['name']).toBe(user.name) token: {
expect(obj['token']['email']).toBe(user.email) 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 () => { 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', { const res = await app.request('http://localhost/api/auth/signin', {
headers, headers,
}) })
let html = await res.text() const html = await res.text()
expect(html).toContain('action="https://example.com/api/auth/callback/credentials"') expect(html).toContain('action="https://example.com/api/auth/callback/credentials"')
}) })
}) })