fix(auth-js): handle x-forwarded headers to detect auth url (#549)
* fix: handle x-forwarded headers to detect auth url * added changesetpull/551/head
parent
39edec1249
commit
d5ebee9c70
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/auth-js': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
handle x-forwarded headers to detect auth url
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
const setOnline = () => setIsOnline(true)
|
const setOnline = () => setIsOnline(true)
|
||||||
const setOffline = () => setIsOnline(false)
|
const setOffline = () => setIsOnline(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
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,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 + ':'
|
||||||
|
if (host) {
|
||||||
|
url.host = host
|
||||||
|
const portMatch = host.match(/:(\d+)$/)
|
||||||
|
if (portMatch) url.port = portMatch[1]
|
||||||
|
else url.port = ''
|
||||||
}
|
}
|
||||||
}
|
return new Request(url.href, req)
|
||||||
|
|
||||||
function setEnvDefaults(env: AuthEnv, config: AuthConfig) {
|
|
||||||
config.secret ??= env.AUTH_SECRET
|
|
||||||
config.basePath ??= '/api/auth'
|
|
||||||
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> {
|
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') ?? '' },
|
||||||
})
|
})
|
||||||
|
@ -123,7 +121,7 @@ export function authHandler(): MiddlewareHandler {
|
||||||
|
|
||||||
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' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue