added auth.js middleware (#326)

* added next-auth middleware

* fix react types

* code cleanup and improve tests

* renamed to authjs

* added example in readme

* Update README.md

* options to set dynamic base paths , urls and credentials in fetch

* update readme

* update readme

* Update README.md

* fix typos and set correct origin for local development
pull/322/head
divyam234 2023-12-29 01:00:25 +05:30 committed by GitHub
parent 009cc77cf5
commit f9859e8fa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 2608 additions and 4193 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/auth-js': major
---
initial support auth.js with hono

View File

@ -0,0 +1,25 @@
name: ci-auth-js
on:
push:
branches: [main]
paths:
- 'packages/auth-js/**'
pull_request:
branches: ['*']
paths:
- 'packages/auth-js/**'
jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/auth-js
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install --frozen-lockfile
- run: yarn build
- run: yarn test

View File

@ -25,6 +25,7 @@
"build:esbuild-transpiler": "yarn workspace @hono/esbuild-transpiler build",
"build:oauth-providers": "yarn workspace @hono/oauth-providers build",
"build:react-renderer": "yarn workspace @hono/react-renderer build",
"build:auth-js": "yarn workspace @hono/auth-js build",
"build": "run-p 'build:*'",
"lint": "eslint 'packages/**/*.{ts,tsx}'",
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
@ -56,4 +57,4 @@
"typescript": "^5.2.2"
},
"packageManager": "yarn@4.0.2"
}
}

View File

@ -0,0 +1,143 @@
# Auth.js middleware for Hono
This is a [Auth.js](https://authjs.dev) third-party middleware for [Hono](https://github.com/honojs/hono).
This middleware can be used to inject the Auth.js session into the request context.
## Installation
```plain
npm i hono @hono/auth-js @auth/core
```
## Configuration
Before starting using the middleware you must set the following environment variables:
```plain
AUTH_SECRET=#required
AUTH_URL=#optional
```
## How to Use
```ts
import { Hono,Context } from 'hono'
import { authHandler, initAuthConfig, verifyAuth, AuthConfig } from "@hono/auth-js"
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 {
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() {
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"
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() {
return (
<SessionProvider>
<Children />
</SessionProvider>
)
}
function Children() {
const { data: session, status } = useSession()
return (
<div >
I am {session?.user}
</div>
)
}
```
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 }
}
```
> [!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
## Author
Divyam <https://github.com/divyam234>

View File

@ -0,0 +1,71 @@
{
"name": "@hono/auth-js",
"version": "1.0.0",
"description": "A third-party Auth js middleware for Hono",
"main": "dist/index.js",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./react": {
"import": {
"types": "./dist/react.d.mts",
"default": "./dist/react.mjs"
},
"require": {
"types": "./dist/react.d.ts",
"default": "./dist/react.js"
}
}
},
"typesVersions": {
"*": {
"react": [
"./dist/react.d.mts"
]
}
},
"files": [
"dist"
],
"scripts": {
"test": "vitest --run",
"build": "tsup",
"prerelease": "yarn build && yarn test",
"release": "yarn publish"
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/honojs/middleware.git"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"@auth/core": "0.*",
"hono": "3.*"
},
"devDependencies": {
"@auth/core": "^0.19.0",
"@types/react": "^18",
"hono": "^3.11.7",
"jest": "^29.7.0",
"react": "^18.2.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^1.0.4"
},
"engines": {
"node": ">=18.4.0"
}
}

View File

@ -0,0 +1,214 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
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'
/** @todo */
class ClientFetchError extends AuthError {}
/** @todo */
export class ClientSessionError extends AuthError {}
export interface AuthClientConfig {
baseUrl: string
basePath: string
credentials?: RequestCredentials,
/** Stores last session response */
_session?: Session | null | undefined
/** Used for timestamp since last sycned (in seconds) */
_lastSync: number
/**
* Stores the `SessionProvider`'s session update method to be able to
* trigger session updates from places like `signIn` or `signOut`
*/
_getSession: (...args: any[]) => any
}
export interface UseSessionOptions<R extends boolean> {
required: R
/** Defaults to `signIn` */
onUnauthenticated?: () => void
}
// Util type that matches some strings literally, but allows any other string as well.
// @source https://github.com/microsoft/TypeScript/issues/29729#issuecomment-832522611
export type LiteralUnion<T extends U, U = string> =
| T
| (U & Record<never, never>)
export interface ClientSafeProvider {
id: LiteralUnion<BuiltInProviderType>
name: string
type: ProviderType
signinUrl: string
callbackUrl: string
}
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
}
export interface SignInResponse {
error: string | undefined
status: number
ok: boolean
url: string | null
}
/**
* Match `inputType` of `new URLSearchParams(inputType)`
* @internal
*/
export type SignInAuthorizationParams =
| string
| string[][]
| Record<string, string>
| URLSearchParams
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1) */
export interface SignOutResponse {
url: string
}
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
}
/**
* If you have session expiry times of 30 days (the default) or more, then you probably don't need to change any of the default options.
*
* However, if you need to customize the session behavior and/or are using short session expiry times, you can pass options to the provider to customize the behavior of the {@link useSession} hook.
*/
export interface SessionProviderProps {
children: React.ReactNode
session?: Session | null
baseUrl?: string
basePath?: string
/**
* A time interval (in seconds) after which the session will be re-fetched.
* If set to `0` (default), the session is not polled.
*/
refetchInterval?: number
/**
* `SessionProvider` automatically refetches the session when the user switches between windows.
* This option activates this behaviour if set to `true` (default).
*/
refetchOnWindowFocus?: boolean
/**
* Set to `false` to stop polling when the device has no internet access offline (determined by `navigator.onLine`)
*
* [`navigator.onLine` documentation](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorOnLine/onLine)
*/
refetchWhenOffline?: false
}
export async function fetchData<T = any>(
path: string,
config: AuthClientConfig,
logger: LoggerInstance,
req: any = {}
): Promise<T | null> {
const url = `${config.baseUrl}${config.basePath}/${path}`
try {
const options: RequestInit = {
headers: {
'Content-Type': 'application/json',
...(req?.headers?.cookie ? { cookie: req.headers.cookie } : {}),
},
credentials: config.credentials,
}
if (req?.body) {
options.body = JSON.stringify(req.body)
options.method = 'POST'
}
const res = await fetch(url, options)
const data = await res.json()
if (!res.ok) throw data
return data as T
} catch (error) {
logger.error(new ClientFetchError((error as Error).message, error as any))
return null
}
}
/** @internal */
export function useOnline() {
const [isOnline, setIsOnline] = React.useState(
typeof navigator !== 'undefined' ? navigator.onLine : false
)
const setOnline = () => setIsOnline(true)
const setOffline = () => setIsOnline(false)
React.useEffect(() => {
window.addEventListener('online', setOnline)
window.addEventListener('offline', setOffline)
return () => {
window.removeEventListener('online', setOnline)
window.removeEventListener('offline', setOffline)
}
}, [])
return isOnline
}
/**
* Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC.
* @internal
*/
export function now() {
return Math.floor(Date.now() / 1000)
}
/**
* Returns an `URL` like object to make requests/redirects from server-side
* @internal
*/
export function parseUrl(url?: string): {
/** @default "http://localhost:3000" */
origin: string
/** @default "localhost:3000" */
host: string
/** @default "/api/auth" */
path: string
/** @default "http://localhost:3000/api/auth" */
base: string
/** @default "http://localhost:3000/api/auth" */
toString: () => string
} {
const defaultUrl = new URL('http://localhost:3000/api/auth')
if (url && !url.startsWith('http')) {
url = `https://${url}`
}
const _url = new URL(url ?? defaultUrl)
const path = (_url.pathname === '/' ? defaultUrl.pathname : _url.pathname)
// Remove trailing slash
.replace(/\/$/, '')
const base = `${_url.origin}${path}`
return {
origin: _url.origin,
host: _url.host,
path,
base,
toString: () => base,
}
}

View File

@ -0,0 +1,121 @@
import type { AuthConfig as AuthConfigCore } from '@auth/core'
import { Auth } from '@auth/core'
import type { AdapterUser } from '@auth/core/adapters'
import type { JWT } from '@auth/core/jwt'
import type { Session } from '@auth/core/types'
import type { Context, MiddlewareHandler } from 'hono'
import { HTTPException } from 'hono/http-exception'
declare module 'hono' {
interface ContextVariableMap {
authUser: AuthUser
authConfig: AuthConfig
}
}
export type AuthEnv = {
AUTH_SECRET: string
AUTH_REDIRECT_PROXY_URL?: string
[key: string]: string | undefined
}
export type AuthUser = {
session: Session
token?: JWT
user?: AdapterUser
}
export interface AuthConfig extends Omit<AuthConfigCore, 'raw'> {}
export type ConfigHandler = (c: Context) => AuthConfig
function reqWithEnvUrl(req: Request, authUrl?: string): Request {
return authUrl ? new Request(new URL(req.url, authUrl).href, req) : req
}
function setEnvDefaults(env: AuthEnv, config: AuthConfig) {
config.secret ??= env.AUTH_SECRET
config.trustHost = true
config.redirectProxyUrl ??= env.AUTH_REDIRECT_PROXY_URL
config.providers = config.providers.map((p) => {
const finalProvider = typeof p === 'function' ? p({}) : p
if (finalProvider.type === 'oauth' || finalProvider.type === 'oidc') {
const ID = finalProvider.id.toUpperCase()
finalProvider.clientId ??= env[`AUTH_${ID}_ID`]
finalProvider.clientSecret ??= env[`AUTH_${ID}_SECRET`]
if (finalProvider.type === 'oidc') {
finalProvider.issuer ??= env[`AUTH_${ID}_ISSUER`]
}
}
return finalProvider
})
}
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
const config = c.get('authConfig')
const origin = new URL(c.req.url, c.env.AUTH_URL).origin
const request = new Request(`${origin}/session`, {
headers: { cookie: c.req.header('cookie') ?? '' },
})
setEnvDefaults(c.env, config)
let authUser: AuthUser = {} as AuthUser
const response = (await Auth(request, {
...config,
callbacks: {
...config.callbacks,
async session(...args) {
authUser = args[0]
const session =
(await config.callbacks?.session?.(...args)) ?? args[0].session
// @ts-expect-error either user or token will be defined
const user = args[0].user ?? args[0].token
return { user, ...session } satisfies Session
},
},
})) as Response
const session = (await response.json()) as Session | null
return session && session.user ? authUser : null
}
export function verifyAuth(): MiddlewareHandler {
return async (c, next) => {
const authUser = await getAuthUser(c)
const isAuth = !!authUser?.token || !!authUser?.user
if (!isAuth) {
const res = new Response('Unauthorized', {
status: 401,
})
throw new HTTPException(401, { res })
} else c.set('authUser', authUser)
await next()
}
}
export function initAuthConfig(cb: ConfigHandler): MiddlewareHandler {
return async (c, next) => {
const config = cb(c)
c.set('authConfig', config)
await next()
}
}
export function authHandler(): MiddlewareHandler {
return async (c) => {
const config = c.get('authConfig')
setEnvDefaults(c.env, config)
if (!config.secret) {
throw new HTTPException(500, { message: 'Missing AUTH_SECRET' })
}
const res = await Auth(reqWithEnvUrl(c.req.raw, c.env.AUTH_URL), config)
return new Response(res.body, res)
}
}

View File

@ -0,0 +1,418 @@
/* 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,
} from './client'
// TODO: Remove/move to core?
export type {
LiteralUnion,
SignInOptions,
SignInAuthorizationParams,
SignOutParams,
SignInResponse,
}
export { SessionProviderProps }
class AuthConfigManager {
private static instance: AuthConfigManager | null = null
_config: AuthClientConfig = {
baseUrl: parseUrl(window.location.origin).origin,
basePath: parseUrl(window.location.origin).path,
credentials:'same-origin',
_lastSync: 0,
_session: undefined,
_getSession: () => {},
}
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
}
}
export const authConfigManager = AuthConfigManager.getInstance()
function broadcast() {
if (typeof BroadcastChannel !== 'undefined') {
return new BroadcastChannel('auth-js')
}
return {
postMessage: () => {},
addEventListener: () => {},
removeEventListener: () => {},
}
}
// TODO:
const logger: LoggerInstance = {
debug: console.debug,
error: console.error,
warn: console.warn,
}
/** @todo Document */
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 const SessionContext = React.createContext?.<SessionContextValue | undefined>(undefined)
export function useSession<R extends boolean>(
options?: UseSessionOptions<R>
): SessionContextValue<R> {
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 { required, onUnauthenticated } = options ?? {}
const requiredAndNotLoading = required && value.status === 'unauthenticated'
React.useEffect(() => {
if (requiredAndNotLoading) {
const url = `${__AUTHJS.baseUrl}${__AUTHJS.basePath}/signin?${new URLSearchParams({
error: 'SessionRequired',
callbackUrl: window.location.href,
})}`
if (onUnauthenticated) onUnauthenticated()
else window.location.href = url
}
}, [requiredAndNotLoading, onUnauthenticated])
if (requiredAndNotLoading) {
return {
data: value.data,
update: value.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
}
/**
* Returns the current Cross-Site Request Forgery Token (CSRF Token)
* required to make requests that changes state. (e.g. signing in or out, or updating the session).
*
* [CSRF Prevention: Double Submit Cookie](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie)
* @internal
*/
export async function getCsrfToken() {
const response = await fetchData<{ csrfToken: string }>('csrf', authConfigManager.getConfig(), logger)
return response?.csrfToken ?? ''
}
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 } = options ?? {}
const __AUTHJS: AuthClientConfig = authConfigManager.getConfig()
const href = `${__AUTHJS.baseUrl}${__AUTHJS.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 isSupportingReturn = isCredentials || isEmail
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',
},
// @ts-expect-error TODO: Fix this
body: new URLSearchParams({ ...options, csrfToken, callbackUrl }),
credentials: __AUTHJS.credentials,
})
const data = await res.json()
// TODO: Do not redirect for Credentials and Email providers by default in next major
if (redirect || !isSupportingReturn) {
const url = (data as any).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')
if (res.ok) {
await __AUTHJS._getSession({ event: 'storage' })
}
return {
error,
status: res.status,
ok: res.ok,
url: error ? null : (data as any).url,
} as any
}
/**
* 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 csrfToken = await getCsrfToken()
const res = await fetch(`${href}/signout`, {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Return-Redirect': '1',
},
body: new URLSearchParams({ csrfToken, callbackUrl }),
credentials: __AUTHJS.credentials,
})
const data = await res.json()
broadcast().postMessage({ event: 'session', data: { trigger: 'signout' } })
if (options?.redirect ?? true) {
const url = (data as any).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')
}
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

@ -0,0 +1,85 @@
import {webcrypto} from 'node:crypto'
import { Hono } from 'hono'
import { describe, expect, it } from 'vitest'
import { authHandler, verifyAuth,initAuthConfig} from '../src'
// @ts-expect-error - global crypto
//needed for node 18 and below but should work in node 20 and above
global.crypto = webcrypto
describe('Auth.js Adapter Middleware', () => {
it('Should return 500 if AUTH_SECRET is missing', async () => {
const app = new Hono()
app.use('/*', (c, next) => {
c.env = {}
return next()
})
app.use(
'/*',
initAuthConfig(() => {
return {
providers: [],
}
})
)
app.use('/api/auth/*', authHandler())
const req = new Request('http://localhost/api/auth/error')
const res = await app.request(req)
expect(res.status).toBe(500)
expect(await res.text()).toBe('Missing AUTH_SECRET')
})
it('Should return 200 auth initial config is correct', async () => {
const app = new Hono()
app.use('/*', (c, next) => {
c.env = {'AUTH_SECRET':'secret'}
return next()
})
app.use(
'/*',
initAuthConfig(() => {
return {
providers: [],
}
})
)
app.use('/api/auth/*', authHandler())
const req = new Request('http://localhost/api/auth/error')
const res = await app.request(req)
expect(res.status).toBe(200)
})
it('Should return 401 is if auth cookie is invalid or missing', async () => {
const app = new Hono()
app.use('/*', (c, next) => {
c.env = {'AUTH_SECRET':'secret'}
return next()
})
app.use(
'/*',
initAuthConfig(() => {
return {
providers: [],
}
})
)
app.use('/api/*', verifyAuth())
app.use('/api/auth/*', authHandler())
app.get('/api/protected', (c)=> c.text('protected'))
const req = new Request('http://localhost/api/protected')
const res = await app.request(req)
expect(res.status).toBe(401)
})
})

View File

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"rootDir": "./",
"outDir": "./dist",
"jsx": "react",
"types": ["jest","node","vitest/globals"]
},
"include": [
"src/**/*.ts","src/**/*.tsx"
],
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts', 'src/react.tsx'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
clean: true,
})

View File

@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true
},
})

5683
yarn.lock

File diff suppressed because it is too large Load Diff