From d5ebee9c70b5c6e9ecdcadd39805a6a7c481c0ee Mon Sep 17 00:00:00 2001 From: divyam234 <47589864+divyam234@users.noreply.github.com> Date: Tue, 28 May 2024 21:48:18 +0530 Subject: [PATCH] fix(auth-js): handle x-forwarded headers to detect auth url (#549) * fix: handle x-forwarded headers to detect auth url * added changeset --- .changeset/funny-dingos-obey.md | 5 ++ packages/auth-js/src/client.ts | 86 ++++------------------------- packages/auth-js/src/index.ts | 46 ++++++++------- packages/auth-js/src/react.tsx | 1 - packages/auth-js/test/index.test.ts | 22 +++++--- 5 files changed, 53 insertions(+), 107 deletions(-) create mode 100644 .changeset/funny-dingos-obey.md diff --git a/.changeset/funny-dingos-obey.md b/.changeset/funny-dingos-obey.md new file mode 100644 index 000000000..b6d3e4729 --- /dev/null +++ b/.changeset/funny-dingos-obey.md @@ -0,0 +1,5 @@ +--- +'@hono/auth-js': patch +--- + +handle x-forwarded headers to detect auth url diff --git a/packages/auth-js/src/client.ts b/packages/auth-js/src/client.ts index c91085d3c..36e979cfd 100644 --- a/packages/auth-js/src/client.ts +++ b/packages/auth-js/src/client.ts @@ -1,38 +1,26 @@ -/* 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 { 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 | (U & Record) export interface ClientSafeProvider { @@ -61,17 +49,12 @@ export interface SignInResponse { url: string | null } -/** - * Match `inputType` of `new URLSearchParams(inputType)` - * @internal - */ export type SignInAuthorizationParams = | string | string[][] | Record | URLSearchParams -/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1) */ export interface SignOutResponse { url: string } @@ -83,32 +66,14 @@ export interface SignOutParams { 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 } @@ -145,16 +110,15 @@ export async function fetchData( } } -/** @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(() => { + const setOnline = () => setIsOnline(true) + const setOffline = () => setIsOnline(false) + window.addEventListener('online', setOnline) window.addEventListener('offline', setOffline) @@ -167,48 +131,22 @@ export function useOnline() { 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(/\/$/, '') +export function parseUrl(url?: string) { + const defaultUrl = 'http://localhost:3000/api/auth'; + const parsedUrl = new URL(url?.startsWith('http') ? url : `https://${url}` || defaultUrl); - const base = `${_url.origin}${path}` + const path = parsedUrl.pathname === '/' ? '/api/auth' : parsedUrl.pathname.replace(/\/$/, ''); + const base = `${parsedUrl.origin}${path}`; return { - origin: _url.origin, - host: _url.host, + origin: parsedUrl.origin, + host: parsedUrl.host, path, base, toString: () => base, - } + }; } diff --git a/packages/auth-js/src/index.ts b/packages/auth-js/src/index.ts index 4ba88a782..9e9baeedb 100644 --- a/packages/auth-js/src/index.ts +++ b/packages/auth-js/src/index.ts @@ -6,6 +6,7 @@ import type { Session } from '@auth/core/types' import type { Context, MiddlewareHandler } from 'hono' import { env } from 'hono/adapter' import { HTTPException } from 'hono/http-exception' +import { setEnvDefaults as coreSetEnvDefaults } from '@auth/core' declare module 'hono' { interface ContextVariableMap { @@ -31,6 +32,12 @@ export interface AuthConfig extends Omit {} 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 { if (authUrl) { 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])) return new Request(reqUrlObj.href, req) } else { - return 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`] - } + 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 finalProvider - }) + return new Request(url.href, req) + } } export async function getAuthUser(c: Context): Promise { const config = c.get('authConfig') let ctxEnv = env(c) as AuthEnv 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`, { headers: { cookie: c.req.header('cookie') ?? '' }, }) @@ -120,14 +118,14 @@ export function authHandler(): MiddlewareHandler { return async (c) => { const config = c.get('authConfig') let ctxEnv = env(c) as AuthEnv - + setEnvDefaults(ctxEnv, config) - if (!config.secret) { + if (!config.secret || config.secret.length === 0) { throw new HTTPException(500, { message: 'Missing AUTH_SECRET' }) } const res = await Auth(reqWithEnvUrl(c.req.raw, ctxEnv.AUTH_URL), config) return new Response(res.body, res) } -} +} \ No newline at end of file diff --git a/packages/auth-js/src/react.tsx b/packages/auth-js/src/react.tsx index 4ff710509..b2b3a6100 100644 --- a/packages/auth-js/src/react.tsx +++ b/packages/auth-js/src/react.tsx @@ -75,7 +75,6 @@ const logger: LoggerInstance = { warn: console.warn, } -/** @todo Document */ export type UpdateSession = (data?: any) => Promise export type SessionContextValue = R extends true diff --git a/packages/auth-js/test/index.test.ts b/packages/auth-js/test/index.test.ts index 5fab774aa..09ecea4b3 100644 --- a/packages/auth-js/test/index.test.ts +++ b/packages/auth-js/test/index.test.ts @@ -16,11 +16,6 @@ describe('Config', () => { globalThis.process.env = { AUTH_SECRET: '' } const app = new Hono() - app.use('/*', (c, next) => { - c.env = {} - return next() - }) - app.use( '/*', initAuthConfig(() => { @@ -29,9 +24,8 @@ describe('Config', () => { } }) ) - 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) expect(res.status).toBe(500) expect(await res.text()).toBe('Missing AUTH_SECRET') @@ -51,7 +45,7 @@ describe('Config', () => { ) 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) expect(res.status).toBe(200) }) @@ -144,6 +138,7 @@ describe('Credentials Provider', () => { secret: 'secret', providers: [credentials], adapter: mockAdapter, + basePath: '/api/auth', skipCSRFCheck, callbacks: { jwt: ({ token, user }) => { @@ -194,4 +189,15 @@ describe('Credentials Provider', () => { 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 () => { + 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"') + }) })