Skip to content

fix(#990): use a dedicated host calculation for authjs #992

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 6, 2025
Merged
2 changes: 1 addition & 1 deletion playground-authjs/pages/protected/locally.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { definePageMeta } from '#imports'

// Note: This is only for testing, it does not make sense to do this with `globalAppMiddleware` turned on
definePageMeta({
middleware: 'auth'
middleware: 'sidebase-auth'
})
</script>

Expand Down
66 changes: 38 additions & 28 deletions src/runtime/composables/authjs/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import type { AppProvider, BuiltInProviderType } from 'next-auth/providers/index
import { defu } from 'defu'
import { type Ref, readonly } from 'vue'
import { appendHeader } from 'h3'
import { determineCallbackUrl, resolveApiUrlPath } from '../../utils/url'
import { resolveApiUrlPath } from '../../utils/url'
import { _fetch } from '../../utils/fetch'
import { isNonEmptyObject } from '../../utils/checkSessionResult'
import type { CommonUseAuthReturn, GetSessionOptions, SignInFunc, SignOutFunc } from '../../types'
import { useTypedBackendConfig } from '../../helpers'
import { getRequestURLWN } from '../common/getRequestURL'
import { determineCallbackUrl } from '../../utils/callbackUrl'
import type { SessionData } from './useAuthState'
import { navigateToAuthPageWN } from './utils/navigateToAuthPage'
import type { NuxtApp } from '#app/nuxt'
Expand All @@ -28,26 +29,24 @@ export type SupportedProviders = LiteralUnion<BuiltInProviderType> | undefined
* Utilities to make nested async composable calls play nicely with nuxt.
*
* Calling nested async composable can lead to "nuxt instance unavailable" errors. See more details here: https://github.com/nuxt/framework/issues/5740#issuecomment-1229197529. To resolve this we can manually ensure that the nuxt-context is set. This module contains `callWithNuxt` helpers for some of the methods that are frequently called in nested `useAuth` composable calls.
*
*/

// eslint-disable-next-line ts/no-empty-object-type
async function getRequestCookies(nuxt: NuxtApp): Promise<{ cookie: string } | {}> {
async function getRequestHeaders(nuxt: NuxtApp, includeCookie = true): Promise<{ cookie?: string, host?: string }> {
// `useRequestHeaders` is sync, so we narrow it to the awaited return type here
const { cookie } = await callWithNuxt(nuxt, () => useRequestHeaders(['cookie']))
if (cookie) {
return { cookie }
const headers = await callWithNuxt(nuxt, () => useRequestHeaders(['cookie', 'host']))
if (includeCookie && headers.cookie) {
return headers
}
return {}
return { host: headers.host }
}

/**
* Get the current Cross-Site Request Forgery token.
*
* You can use this to pass along for certain requests, most of the time you will not need it.
*/
async function getCsrfToken() {
const nuxt = useNuxtApp()
const headers = await getRequestCookies(nuxt)
const headers = await getRequestHeaders(nuxt)
return _fetch<{ csrfToken: string }>(nuxt, '/csrf', { headers }).then(response => response.csrfToken)
}
function getCsrfTokenWithNuxt(nuxt: NuxtApp) {
Expand All @@ -70,7 +69,7 @@ const signIn: SignInFunc<SupportedProviders, SignInResult> = async (provider, op
const configuredProviders = await getProviders()
if (!configuredProviders) {
const errorUrl = resolveApiUrlPath('error', runtimeConfig)
return navigateToAuthPageWN(nuxt, errorUrl)
return navigateToAuthPageWN(nuxt, errorUrl, true)
}

// 2. If no `provider` was given, either use the configured `defaultProvider` or `undefined` (leading to a forward to the `/login` page with all providers)
Expand All @@ -83,23 +82,19 @@ const signIn: SignInFunc<SupportedProviders, SignInResult> = async (provider, op
// 3. Redirect to the general sign-in page with all providers in case either no provider or no valid provider was selected
const { redirect = true } = options ?? {}

let { callbackUrl } = options ?? {}

if (typeof callbackUrl === 'undefined' && backendConfig.addDefaultCallbackUrl) {
callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, () => getRequestURLWN(nuxt))
}
const callbackUrl = await callWithNuxt(nuxt, () => determineCallbackUrl(runtimeConfig.public.auth, options?.callbackUrl))

const signinUrl = resolveApiUrlPath('signin', runtimeConfig)

const queryParams = callbackUrl ? `?${new URLSearchParams({ callbackUrl })}` : ''
const hrefSignInAllProviderPage = `${signinUrl}${queryParams}`
if (!provider) {
return navigateToAuthPageWN(nuxt, hrefSignInAllProviderPage)
return navigateToAuthPageWN(nuxt, hrefSignInAllProviderPage, true)
}

const selectedProvider = configuredProviders[provider]
if (!selectedProvider) {
return navigateToAuthPageWN(nuxt, hrefSignInAllProviderPage)
return navigateToAuthPageWN(nuxt, hrefSignInAllProviderPage, true)
}

// 4. Perform a sign-in straight away with the selected provider
Expand All @@ -114,9 +109,9 @@ const signIn: SignInFunc<SupportedProviders, SignInResult> = async (provider, op

const csrfToken = await callWithNuxt(nuxt, getCsrfToken)

const headers: { 'Content-Type': string, 'cookie'?: string | undefined } = {
const headers: { 'Content-Type': string, 'cookie'?: string, 'host'?: string } = {
'Content-Type': 'application/x-www-form-urlencoded',
...(await getRequestCookies(nuxt))
...(await getRequestHeaders(nuxt))
}

// @ts-expect-error `options` is typed as any, but is a valid parameter for URLSearchParams
Expand Down Expand Up @@ -155,8 +150,16 @@ const signIn: SignInFunc<SupportedProviders, SignInResult> = async (provider, op
/**
* Get all configured providers from the backend. You can use this method to build your own sign-in page.
*/
function getProviders() {
return _fetch<Record<Exclude<SupportedProviders, undefined>, Omit<AppProvider, 'options'> | undefined>>(useNuxtApp(), '/providers')
async function getProviders() {
const nuxt = useNuxtApp()
// Pass the `Host` header when making internal requests
const headers = await getRequestHeaders(nuxt, false)

return _fetch<Record<Exclude<SupportedProviders, undefined>, Omit<AppProvider, 'options'> | undefined>>(
nuxt,
'/providers',
{ headers }
)
}

/**
Expand All @@ -181,7 +184,7 @@ async function getSession(getSessionOptions?: GetSessionOptions): Promise<Sessio
loading.value = false
}

const headers = await getRequestCookies(nuxt)
const headers = await getRequestHeaders(nuxt)

return _fetch<SessionData>(nuxt, '/session', {
onResponse: ({ response }) => {
Expand Down Expand Up @@ -234,25 +237,32 @@ function getSessionWithNuxt(nuxt: NuxtApp) {
*/
const signOut: SignOutFunc = async (options) => {
const nuxt = useNuxtApp()
const runtimeConfig = useRuntimeConfig()

const requestURL = await getRequestURLWN(nuxt)
const { callbackUrl = requestURL, redirect = true } = options ?? {}
const { callbackUrl: userCallbackUrl, redirect = true } = options ?? {}
const csrfToken = await getCsrfTokenWithNuxt(nuxt)

// Determine the correct callback URL
const callbackUrl = await determineCallbackUrl(
runtimeConfig.public.auth,
userCallbackUrl,
true
)

if (!csrfToken) {
throw createError({ statusCode: 400, statusMessage: 'Could not fetch CSRF Token for signing out' })
}

const callbackUrlFallback = requestURL
const signoutData = await _fetch<{ url: string }>(nuxt, '/signout', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
'Content-Type': 'application/x-www-form-urlencoded',
...(await getRequestHeaders(nuxt))
},
onRequest: ({ options }) => {
options.body = new URLSearchParams({
csrfToken: csrfToken as string,
callbackUrl: callbackUrl || callbackUrlFallback,
callbackUrl,
json: 'true'
})
}
Expand Down
23 changes: 12 additions & 11 deletions src/runtime/composables/authjs/utils/navigateToAuthPage.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { hasProtocol, isScriptProtocol, joinURL } from 'ufo'
import { type NuxtApp, abortNavigation, callWithNuxt, useNuxtApp, useRouter, useRuntimeConfig } from '#app'
import { hasProtocol, isScriptProtocol } from 'ufo'
import { type NuxtApp, abortNavigation, callWithNuxt, useRouter } from '#app'

export function navigateToAuthPageWN(nuxt: NuxtApp, href: string) {
return callWithNuxt(nuxt, navigateToAuthPage, [href])
export function navigateToAuthPageWN(nuxt: NuxtApp, href: string, isInternalRouting?: boolean) {
return callWithNuxt(nuxt, navigateToAuthPage, [nuxt, href, isInternalRouting])
}

const URL_QUOTE_RE = /"/g
Expand All @@ -17,14 +17,14 @@ const URL_QUOTE_RE = /"/g
*
* Adapted from https://github.com/nuxt/nuxt/blob/16d213bbdcc69c0cc72afb355755ff877654a374/packages/nuxt/src/app/composables/router.ts#L119-L217
*
* @param nuxt Nuxt app context
* @param href HREF / URL to navigate to
*/
export function navigateToAuthPage(href: string) {
function navigateToAuthPage(nuxt: NuxtApp, href: string, isInternalRouting = false) {
const router = useRouter()
const nuxtApp = useNuxtApp()

if (import.meta.server) {
if (nuxtApp.ssrContext) {
if (nuxt.ssrContext) {
const isExternalHost = hasProtocol(href, { acceptRelative: true })
if (isExternalHost) {
const { protocol } = new URL(href, 'http://localhost')
Expand All @@ -33,14 +33,15 @@ export function navigateToAuthPage(href: string) {
}
}

const fullPath = isExternalHost ? href : router.resolve(href).fullPath || '/'
const location = isExternalHost ? href : joinURL(useRuntimeConfig().app.baseURL, fullPath)
// This is a difference with `nuxt/nuxt` - we do not add `app.baseURL` here because all consumers are responsible for it
// We also skip resolution for internal routing to avoid triggering `No match found` warning from Vue Router
const location = isExternalHost || isInternalRouting ? href : router.resolve(href).fullPath || '/'

// TODO: consider deprecating in favour of `app:rendered` and removing
return nuxtApp.callHook('app:redirected').then(() => {
return nuxt.callHook('app:redirected').then(() => {
const encodedLoc = location.replace(URL_QUOTE_RE, '%22')
const encodedHeader = encodeURL(location, isExternalHost)
nuxtApp.ssrContext!._renderResponse = {
nuxt.ssrContext!._renderResponse = {
statusCode: 302,
body: `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`,
headers: { location: encodedHeader },
Expand Down
21 changes: 11 additions & 10 deletions src/runtime/composables/local/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { type Ref, readonly } from 'vue'
import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignInFunc, SignOutFunc, SignUpOptions } from '../../types'
import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../../helpers'
import { _fetch } from '../../utils/fetch'
import { determineCallbackUrl } from '../../utils/url'
import { getRequestURLWN } from '../common/getRequestURL'
import { ERROR_PREFIX } from '../../utils/logger'
import { determineCallbackUrl } from '../../utils/callbackUrl'
import { formatToken } from './utils/token'
import { type UseAuthStateReturn, useAuthState } from './useAuthState'
import { callWithNuxt } from '#app/nuxt'
Expand Down Expand Up @@ -63,15 +63,10 @@ const signIn: SignInFunc<Credentials, any> = async (credentials, signInOptions,
}

if (redirect) {
let { callbackUrl } = signInOptions ?? {}
let callbackUrl = signInOptions?.callbackUrl
if (typeof callbackUrl === 'undefined') {
const redirectQueryParam = useRoute()?.query?.redirect
if (redirectQueryParam) {
callbackUrl = redirectQueryParam.toString()
}
else {
callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, () => getRequestURLWN(nuxt))
}
callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString())
}

return navigateTo(callbackUrl, { external })
Expand Down Expand Up @@ -108,9 +103,15 @@ const signOut: SignOutFunc = async (signOutOptions) => {
res = await _fetch(nuxt, path, { method, headers, body })
}

const { callbackUrl, redirect = true, external } = signOutOptions ?? {}
const { redirect = true, external } = signOutOptions ?? {}

if (redirect) {
await navigateTo(callbackUrl ?? await getRequestURLWN(nuxt), { external })
let callbackUrl = signOutOptions?.callbackUrl
if (typeof callbackUrl === 'undefined') {
const redirectQueryParam = useRoute()?.query?.redirect
callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString(), true)
}
await navigateTo(callbackUrl, { external })
}

return res
Expand Down
14 changes: 10 additions & 4 deletions src/runtime/middleware/sidebase-auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { determineCallbackUrl, isExternalUrl } from '../utils/url'
import { isExternalUrl } from '../utils/url'
import { isProduction } from '../helpers'
import { ERROR_PREFIX } from '../utils/logger'
import { determineCallbackUrlForRouteMiddleware } from '../utils/callbackUrl'
import { defineNuxtRouteMiddleware, navigateTo, useAuth, useRuntimeConfig } from '#imports'

type MiddlewareMeta = boolean | {
Expand Down Expand Up @@ -88,9 +89,14 @@ export default defineNuxtRouteMiddleware((to) => {
}

if (authConfig.provider.type === 'authjs') {
const signInOptions: Parameters<typeof signIn>[1] = { error: 'SessionRequired', callbackUrl: determineCallbackUrl(authConfig, () => to.fullPath) }
// eslint-disable-next-line ts/ban-ts-comment
// @ts-ignore This is valid for a backend-type of `authjs`, where sign-in accepts a provider as a first argument
const callbackUrl = determineCallbackUrlForRouteMiddleware(authConfig, to)

const signInOptions: Parameters<typeof signIn>[1] = {
error: 'SessionRequired',
callbackUrl
}

// @ts-expect-error This is valid for a backend-type of `authjs`, where sign-in accepts a provider as a first argument
return signIn(undefined, signInOptions) as Promise<void>
}

Expand Down
9 changes: 6 additions & 3 deletions src/runtime/server/plugins/assertOrigin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
*/
import type { NitroApp } from 'nitropack/types'
import { ERROR_MESSAGES } from '../services/errors'
import { isProduction } from '../../helpers'
import { getServerOrigin } from '../services/utils'
import { isProduction, useTypedBackendConfig } from '../../helpers'
import { getServerBaseUrl } from '../services/authjs/utils'
import { useRuntimeConfig } from '#imports'

// type stub
type NitroAppPlugin = (nitro: NitroApp) => void
Expand All @@ -16,7 +17,9 @@ function defineNitroPlugin(def: NitroAppPlugin): NitroAppPlugin {
// Export runtime plugin
export default defineNitroPlugin(() => {
try {
getServerOrigin()
const runtimeConfig = useRuntimeConfig()
const trustHostUserPreference = useTypedBackendConfig(runtimeConfig, 'authjs').trustHost
getServerBaseUrl(runtimeConfig, false, trustHostUserPreference, isProduction)
}
catch (error) {
if (!isProduction) {
Expand Down
Loading