Skip to content
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

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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
60 changes: 35 additions & 25 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 Down Expand Up @@ -83,11 +82,7 @@ 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 determineCallbackUrl(runtimeConfig.public.auth, options?.callbackUrl)

const signinUrl = resolveApiUrlPath('signin', runtimeConfig)

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
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
77 changes: 20 additions & 57 deletions src/runtime/server/services/authjs/nuxtAuthHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IncomingHttpHeaders } from 'node:http'
import { createError, eventHandler, getHeaders, getQuery, getRequestHost, getRequestProtocol, getResponseHeader, isMethod, parseCookies, readBody, sendRedirect, setCookie, setResponseHeader } from 'h3'
import { createError, eventHandler, getHeaders, getQuery, getResponseHeader, isMethod, parseCookies, readBody, sendRedirect, setCookie, setResponseHeader } from 'h3'
import type { H3Event } from 'h3'
import type { CookieSerializeOptions } from 'cookie-es'

Expand All @@ -13,11 +13,13 @@ import { defu } from 'defu'
import { joinURL } from 'ufo'
import { ERROR_MESSAGES } from '../errors'
import { isNonEmptyObject } from '../../../utils/checkSessionResult'
import { getServerOrigin } from '../utils'
import { useTypedBackendConfig } from '../../../helpers'
import { isProduction, useTypedBackendConfig } from '../../../helpers'
import { resolveApiBaseURL } from '../../../utils/url'
import { getHostValueForAuthjs, getServerBaseUrl } from './utils'
import { useRuntimeConfig } from '#imports'

type RuntimeConfig = ReturnType<typeof useRuntimeConfig>

let preparedAuthjsHandler: ((req: RequestInternal) => Promise<ResponseInternal>) | undefined
let usedSecret: string | undefined

Expand All @@ -43,7 +45,7 @@ export function NuxtAuthHandler(nuxtAuthOptions?: AuthOptions) {
logger: undefined,
providers: [],

// SAFETY: We trust host here because `getRequestURLFromH3Event` is responsible for producing a trusted URL
// SAFETY: We trust host here because `getHostValueForAuthjs` is responsible for producing a trusted URL
trustHost: true,

// AuthJS uses `/auth` as default, but we rely on `/api/auth` (same as in previous `next-auth`)
Expand All @@ -64,7 +66,7 @@ export function NuxtAuthHandler(nuxtAuthOptions?: AuthOptions) {
const { res } = event.node

// 1.1. Assemble and perform request to the NextAuth.js auth handler
const nextRequest = await createRequestForAuthjs(event, trustHostUserPreference)
const nextRequest = await createRequestForAuthjs(event, runtimeConfig, trustHostUserPreference)

// 1.2. Call Authjs
// Safety: `preparedAuthjsHandler` was assigned earlier and never re-assigned
Expand Down Expand Up @@ -122,10 +124,6 @@ export async function getServerSession(event: H3Event) {
}
}

// Build a correct endpoint
const sessionUrlBase = getRequestBaseFromH3Event(event, trustHostUserPreference)
const sessionUrl = new URL(sessionUrlPath, sessionUrlBase)

// Create a virtual Request to check the session
const authjsRequest: RequestInternal = {
action: 'session',
Expand All @@ -135,8 +133,8 @@ export async function getServerSession(event: H3Event) {
cookies: parseCookies(event),
providerId: undefined,
error: undefined,
host: sessionUrl.href,
query: Object.fromEntries(sessionUrl.searchParams)
host: getHostValueForAuthjs(event, runtimeConfig, trustHostUserPreference, isProduction),
query: {}
}

// Invoke Auth.js
Expand All @@ -163,14 +161,17 @@ export async function getServerSession(event: H3Event) {
* @param eventAndOptions.secret A secret string used for encryption
*/
export function getToken<R extends boolean = false>({ event, secureCookie, secret, ...rest }: Omit<GetTokenParams<R>, 'req'> & { event: H3Event }) {
const runtimeConfig = useRuntimeConfig()
const trustHostUserPreference = useTypedBackendConfig(runtimeConfig, 'authjs').trustHost

return authjsGetToken({
// @ts-expect-error As our request is not a real next-auth request, we pass down only what's required for the method, as per code from https://github.com/nextauthjs/next-auth/blob/8387c78e3fef13350d8a8c6102caeeb05c70a650/packages/next-auth/src/jwt/index.ts#L68
req: {
cookies: parseCookies(event),
headers: getHeaders(event) as IncomingHttpHeaders
},
// see https://github.com/nextauthjs/next-auth/blob/8387c78e3fef13350d8a8c6102caeeb05c70a650/packages/next-auth/src/jwt/index.ts#L73
secureCookie: secureCookie ?? getServerOrigin(event).startsWith('https://'),
secureCookie: secureCookie ?? getServerBaseUrl(runtimeConfig, false, trustHostUserPreference, isProduction, event).startsWith('https://'),
secret: secret || usedSecret,
...rest
})
Expand All @@ -182,9 +183,14 @@ export function getToken<R extends boolean = false>({ event, secureCookie, secre
*
* @param event H3Event to transform into `RequestInternal`
*/
async function createRequestForAuthjs(event: H3Event, trustHostUserPreference: boolean): Promise<RequestInternal> {
async function createRequestForAuthjs(
event: H3Event,
runtimeConfig: RuntimeConfig,
trustHostUserPreference: boolean
): Promise<RequestInternal> {
const nextRequest: Omit<RequestInternal, 'action'> = {
host: getRequestURLFromH3Event(event, trustHostUserPreference).href,
// `authjs` expects the baseURL here despite the param name
host: getHostValueForAuthjs(event, runtimeConfig, trustHostUserPreference, isProduction),
body: undefined,
cookies: parseCookies(event),
query: undefined,
Expand Down Expand Up @@ -217,49 +223,6 @@ async function createRequestForAuthjs(event: H3Event, trustHostUserPreference: b
}
}

/**
* Get the request url or construct it.
* Adapted from `h3` to also account for server origin.
*
* ## WARNING
* Please ensure that any URL produced by this function has a trusted host!
*
* @param event The H3 Event containing the request
* @param trustHost Whether the host can be trusted. If `true`, base will be inferred from the request, otherwise the configured origin will be used.
* @throws {Error} When server origin was incorrectly configured or when URL building failed
*/
function getRequestURLFromH3Event(event: H3Event, trustHost: boolean): URL {
const path = (event.node.req.originalUrl || event.path).replace(
/^[/\\]+/g,
'/'
)
const base = getRequestBaseFromH3Event(event, trustHost)
return new URL(path, base)
}

/**
* Gets the request base in the form of origin.
*
* ## WARNING
* Please ensure that any URL produced by this function has a trusted host!
*
* @param event The H3 Event containing the request
* @param trustHost Whether the host can be trusted. If `true`, base will be inferred from the request, otherwise the configured origin will be used.
* @throws {Error} When server origin was incorrectly configured
*/
function getRequestBaseFromH3Event(event: H3Event, trustHost: boolean): string {
if (trustHost) {
const host = getRequestHost(event, { xForwardedHost: trustHost })
const protocol = getRequestProtocol(event)

return `${protocol}://${host}`
}
// This may throw, we don't catch it
const origin = getServerOrigin(event)

return origin
}

/** Actions supported by auth handler */
const SUPPORTED_ACTIONS: AuthAction[] = ['providers', 'session', 'csrf', 'signin', 'signout', 'callback', 'verify-request', 'error', '_log']

Expand Down
Loading