fix(auth-js): handle x-forwarded headers to detect auth url (#549)

* fix: handle  x-forwarded headers to detect auth url

* added changeset
pull/551/head
divyam234 2024-05-28 21:48:18 +05:30 committed by GitHub
parent 39edec1249
commit d5ebee9c70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 53 additions and 107 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/auth-js': patch
---
handle x-forwarded headers to detect auth url

View File

@ -1,38 +1,26 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
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 * as React from 'react'
/** @todo */
class ClientFetchError extends AuthError {} class ClientFetchError extends AuthError {}
/** @todo */
export class ClientSessionError extends AuthError {} export class ClientSessionError extends AuthError {}
export interface AuthClientConfig { export interface AuthClientConfig {
baseUrl: string baseUrl: string
basePath: string basePath: string
credentials?: RequestCredentials credentials?: RequestCredentials
/** Stores last session response */
_session?: Session | null | undefined _session?: Session | null | undefined
/** Used for timestamp since last sycned (in seconds) */
_lastSync: number _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 _getSession: (...args: any[]) => any
} }
export interface UseSessionOptions<R extends boolean> { export interface UseSessionOptions<R extends boolean> {
required: R required: R
/** Defaults to `signIn` */
onUnauthenticated?: () => void 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 type LiteralUnion<T extends U, U = string> = T | (U & Record<never, never>)
export interface ClientSafeProvider { export interface ClientSafeProvider {
@ -61,17 +49,12 @@ export interface SignInResponse {
url: string | null url: string | null
} }
/**
* Match `inputType` of `new URLSearchParams(inputType)`
* @internal
*/
export type SignInAuthorizationParams = export type SignInAuthorizationParams =
| string | string
| string[][] | string[][]
| Record<string, string> | Record<string, string>
| URLSearchParams | URLSearchParams
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1) */
export interface SignOutResponse { export interface SignOutResponse {
url: string url: string
} }
@ -83,32 +66,14 @@ export interface SignOutParams<R extends boolean = true> {
redirect?: R 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 { export interface SessionProviderProps {
children: React.ReactNode children: React.ReactNode
session?: Session | null session?: Session | null
baseUrl?: string baseUrl?: string
basePath?: 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 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 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 refetchWhenOffline?: false
} }
@ -145,16 +110,15 @@ export async function fetchData<T = any>(
} }
} }
/** @internal */
export function useOnline() { export function useOnline() {
const [isOnline, setIsOnline] = React.useState( const [isOnline, setIsOnline] = React.useState(
typeof navigator !== 'undefined' ? navigator.onLine : false typeof navigator !== 'undefined' ? navigator.onLine : false
) )
const setOnline = () => setIsOnline(true)
const setOffline = () => setIsOnline(false)
React.useEffect(() => { React.useEffect(() => {
const setOnline = () => setIsOnline(true)
const setOffline = () => setIsOnline(false)
window.addEventListener('online', setOnline) window.addEventListener('online', setOnline)
window.addEventListener('offline', setOffline) window.addEventListener('offline', setOffline)
@ -167,48 +131,22 @@ export function useOnline() {
return isOnline return isOnline
} }
/**
* Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC.
* @internal
*/
export function now() { export function now() {
return Math.floor(Date.now() / 1000) return Math.floor(Date.now() / 1000)
} }
/** export function parseUrl(url?: string) {
* Returns an `URL` like object to make requests/redirects from server-side const defaultUrl = 'http://localhost:3000/api/auth';
* @internal const parsedUrl = new URL(url?.startsWith('http') ? url : `https://${url}` || defaultUrl);
*/
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')) { const path = parsedUrl.pathname === '/' ? '/api/auth' : parsedUrl.pathname.replace(/\/$/, '');
url = `https://${url}` const base = `${parsedUrl.origin}${path}`;
}
const _url = new URL(url ?? defaultUrl)
const path = (_url.pathname === '/' ? defaultUrl.pathname : _url.pathname)
// Remove trailing slash
.replace(/\/$/, '')
const base = `${_url.origin}${path}`
return { return {
origin: _url.origin, origin: parsedUrl.origin,
host: _url.host, host: parsedUrl.host,
path, path,
base, base,
toString: () => base, toString: () => base,
} };
} }

View File

@ -6,6 +6,7 @@ import type { Session } from '@auth/core/types'
import type { Context, MiddlewareHandler } from 'hono' import type { Context, MiddlewareHandler } from 'hono'
import { env } from 'hono/adapter' import { env } from 'hono/adapter'
import { HTTPException } from 'hono/http-exception' import { HTTPException } from 'hono/http-exception'
import { setEnvDefaults as coreSetEnvDefaults } from '@auth/core'
declare module 'hono' { declare module 'hono' {
interface ContextVariableMap { interface ContextVariableMap {
@ -31,6 +32,12 @@ export interface AuthConfig extends Omit<AuthConfigCore, 'raw'> {}
export type ConfigHandler = (c: Context) => AuthConfig export type ConfigHandler = (c: Context) => AuthConfig
export function setEnvDefaults(env: AuthEnv, config: AuthConfig) {
config.secret ??= env.AUTH_SECRET
config.basePath ||= '/api/auth'
coreSetEnvDefaults(env, config)
}
export function reqWithEnvUrl(req: Request, authUrl?: string): Request { export function reqWithEnvUrl(req: Request, authUrl?: string): Request {
if (authUrl) { if (authUrl) {
const reqUrlObj = new URL(req.url) const reqUrlObj = new URL(req.url)
@ -39,34 +46,25 @@ export function reqWithEnvUrl(req: Request, authUrl?: string): Request {
props.forEach((prop) => (reqUrlObj[prop] = authUrlObj[prop])) props.forEach((prop) => (reqUrlObj[prop] = authUrlObj[prop]))
return new Request(reqUrlObj.href, req) return new Request(reqUrlObj.href, req)
} else { } else {
return req const url = new URL(req.url)
} const proto = req.headers.get('x-forwarded-proto')
} const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host')
if (proto != null) url.protocol = proto.endsWith(':') ? proto : proto + ':'
function setEnvDefaults(env: AuthEnv, config: AuthConfig) { if (host) {
config.secret ??= env.AUTH_SECRET url.host = host
config.basePath ??= '/api/auth' const portMatch = host.match(/:(\d+)$/)
config.trustHost = true if (portMatch) url.port = portMatch[1]
config.redirectProxyUrl ??= env.AUTH_REDIRECT_PROXY_URL else url.port = ''
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 return new Request(url.href, req)
}) }
} }
export async function getAuthUser(c: Context): Promise<AuthUser | null> { export async function getAuthUser(c: Context): Promise<AuthUser | null> {
const config = c.get('authConfig') const config = c.get('authConfig')
let ctxEnv = env(c) as AuthEnv let ctxEnv = env(c) as AuthEnv
setEnvDefaults(ctxEnv, config) setEnvDefaults(ctxEnv, config)
const origin = ctxEnv.AUTH_URL ? new URL(ctxEnv.AUTH_URL).origin : new URL(c.req.url).origin const origin = new URL(reqWithEnvUrl(c.req.raw, ctxEnv.AUTH_URL).url).origin
const request = new Request(`${origin}${config.basePath}/session`, { const request = new Request(`${origin}${config.basePath}/session`, {
headers: { cookie: c.req.header('cookie') ?? '' }, headers: { cookie: c.req.header('cookie') ?? '' },
}) })
@ -120,14 +118,14 @@ export function authHandler(): MiddlewareHandler {
return async (c) => { return async (c) => {
const config = c.get('authConfig') const config = c.get('authConfig')
let ctxEnv = env(c) as AuthEnv let ctxEnv = env(c) as AuthEnv
setEnvDefaults(ctxEnv, config) setEnvDefaults(ctxEnv, config)
if (!config.secret) { if (!config.secret || config.secret.length === 0) {
throw new HTTPException(500, { message: 'Missing AUTH_SECRET' }) throw new HTTPException(500, { message: 'Missing AUTH_SECRET' })
} }
const res = await Auth(reqWithEnvUrl(c.req.raw, ctxEnv.AUTH_URL), config) const res = await Auth(reqWithEnvUrl(c.req.raw, ctxEnv.AUTH_URL), config)
return new Response(res.body, res) return new Response(res.body, res)
} }
} }

View File

@ -75,7 +75,6 @@ const logger: LoggerInstance = {
warn: console.warn, warn: console.warn,
} }
/** @todo Document */
export type UpdateSession = (data?: any) => Promise<Session | null> export type UpdateSession = (data?: any) => Promise<Session | null>
export type SessionContextValue<R extends boolean = false> = R extends true export type SessionContextValue<R extends boolean = false> = R extends true

View File

@ -16,11 +16,6 @@ describe('Config', () => {
globalThis.process.env = { AUTH_SECRET: '' } globalThis.process.env = { AUTH_SECRET: '' }
const app = new Hono() const app = new Hono()
app.use('/*', (c, next) => {
c.env = {}
return next()
})
app.use( app.use(
'/*', '/*',
initAuthConfig(() => { initAuthConfig(() => {
@ -29,9 +24,8 @@ describe('Config', () => {
} }
}) })
) )
app.use('/api/auth/*', authHandler()) app.use('/api/auth/*', authHandler())
const req = new Request('http://localhost/api/auth/error') const req = new Request('http://localhost/api/auth/signin')
const res = await app.request(req) const res = await app.request(req)
expect(res.status).toBe(500) expect(res.status).toBe(500)
expect(await res.text()).toBe('Missing AUTH_SECRET') expect(await res.text()).toBe('Missing AUTH_SECRET')
@ -51,7 +45,7 @@ describe('Config', () => {
) )
app.use('/api/auth/*', authHandler()) app.use('/api/auth/*', authHandler())
const req = new Request('http://localhost/api/auth/error') const req = new Request('http://localhost/api/auth/signin')
const res = await app.request(req) const res = await app.request(req)
expect(res.status).toBe(200) expect(res.status).toBe(200)
}) })
@ -144,6 +138,7 @@ describe('Credentials Provider', () => {
secret: 'secret', secret: 'secret',
providers: [credentials], providers: [credentials],
adapter: mockAdapter, adapter: mockAdapter,
basePath: '/api/auth',
skipCSRFCheck, skipCSRFCheck,
callbacks: { callbacks: {
jwt: ({ token, user }) => { jwt: ({ token, user }) => {
@ -194,4 +189,15 @@ describe('Credentials Provider', () => {
expect(obj['token']['name']).toBe(user.name) expect(obj['token']['name']).toBe(user.name)
expect(obj['token']['email']).toBe(user.email) expect(obj['token']['email']).toBe(user.email)
}) })
it('Should respect x-forwarded-proto and x-forwarded-host', async () => {
const headers = new Headers()
headers.append('x-forwarded-proto', "https")
headers.append('x-forwarded-host', "example.com")
const res = await app.request('http://localhost/api/auth/signin', {
headers,
})
let html = await res.text()
expect(html).toContain('action="https://example.com/api/auth/callback/credentials"')
})
}) })