diff --git a/playground-authjs/pages/protected/locally.vue b/playground-authjs/pages/protected/locally.vue index c70d0356..dd3dbacf 100644 --- a/playground-authjs/pages/protected/locally.vue +++ b/playground-authjs/pages/protected/locally.vue @@ -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' }) diff --git a/src/runtime/composables/authjs/useAuth.ts b/src/runtime/composables/authjs/useAuth.ts index 28ee7c2a..8688e466 100644 --- a/src/runtime/composables/authjs/useAuth.ts +++ b/src/runtime/composables/authjs/useAuth.ts @@ -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' @@ -28,18 +29,16 @@ export type SupportedProviders = LiteralUnion | 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. * @@ -47,7 +46,7 @@ async function getRequestCookies(nuxt: NuxtApp): Promise<{ cookie: string } | {} */ 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) { @@ -83,11 +82,7 @@ const signIn: SignInFunc = 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) @@ -114,9 +109,9 @@ const signIn: SignInFunc = 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 @@ -155,8 +150,16 @@ const signIn: SignInFunc = 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, Omit | 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, Omit | undefined>>( + nuxt, + '/providers', + { headers } + ) } /** @@ -181,7 +184,7 @@ async function getSession(getSessionOptions?: GetSessionOptions): Promise(nuxt, '/session', { onResponse: ({ response }) => { @@ -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' }) } diff --git a/src/runtime/composables/local/useAuth.ts b/src/runtime/composables/local/useAuth.ts index 9419758e..2c2dfa1e 100644 --- a/src/runtime/composables/local/useAuth.ts +++ b/src/runtime/composables/local/useAuth.ts @@ -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' @@ -63,15 +63,10 @@ const signIn: SignInFunc = 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 }) @@ -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 diff --git a/src/runtime/middleware/sidebase-auth.ts b/src/runtime/middleware/sidebase-auth.ts index 76409d69..f5dd7d6b 100644 --- a/src/runtime/middleware/sidebase-auth.ts +++ b/src/runtime/middleware/sidebase-auth.ts @@ -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 | { @@ -88,9 +89,14 @@ export default defineNuxtRouteMiddleware((to) => { } if (authConfig.provider.type === 'authjs') { - const signInOptions: Parameters[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[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 } diff --git a/src/runtime/server/plugins/assertOrigin.ts b/src/runtime/server/plugins/assertOrigin.ts index 7922a497..61ca5358 100644 --- a/src/runtime/server/plugins/assertOrigin.ts +++ b/src/runtime/server/plugins/assertOrigin.ts @@ -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 @@ -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) { diff --git a/src/runtime/server/services/authjs/nuxtAuthHandler.ts b/src/runtime/server/services/authjs/nuxtAuthHandler.ts index 682683f5..f5ec0527 100644 --- a/src/runtime/server/services/authjs/nuxtAuthHandler.ts +++ b/src/runtime/server/services/authjs/nuxtAuthHandler.ts @@ -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' @@ -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 + let preparedAuthjsHandler: ((req: RequestInternal) => Promise) | undefined let usedSecret: string | undefined @@ -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`) @@ -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 @@ -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', @@ -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 @@ -163,6 +161,9 @@ export async function getServerSession(event: H3Event) { * @param eventAndOptions.secret A secret string used for encryption */ export function getToken({ event, secureCookie, secret, ...rest }: Omit, '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: { @@ -170,7 +171,7 @@ export function getToken({ event, secureCookie, secre 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 }) @@ -182,9 +183,14 @@ export function getToken({ event, secureCookie, secre * * @param event H3Event to transform into `RequestInternal` */ -async function createRequestForAuthjs(event: H3Event, trustHostUserPreference: boolean): Promise { +async function createRequestForAuthjs( + event: H3Event, + runtimeConfig: RuntimeConfig, + trustHostUserPreference: boolean +): Promise { const nextRequest: Omit = { - 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, @@ -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'] diff --git a/src/runtime/server/services/authjs/utils.ts b/src/runtime/server/services/authjs/utils.ts new file mode 100644 index 00000000..14b70f27 --- /dev/null +++ b/src/runtime/server/services/authjs/utils.ts @@ -0,0 +1,94 @@ +import type { H3Event } from 'h3' +import { getRequestURL } from 'h3' +import { parseURL, withLeadingSlash } from 'ufo' +import { type RuntimeConfig, resolveApiBaseURL } from '../../../utils/url' +import { ERROR_MESSAGES } from '../errors' + +/** + * Gets the correct value of `host` (later renamed to `origin`) configuration parameter for `authjs` InternalRequest. + * This is actually neither `Host` nor `Origin`, but a base URL (`authjs` naming is misleading) including path. + * + * When user specifies `trustHost`, we would use the `event` to compute the base URL by using full request URL minus the `/action` and `/provider` parts. + * + * ## WARNING + * Please ensure that any URL produced by this function has a trusted host! + * + * @example + * ``` + * // configured baseURL = https://your.domain/api/auth + * + * // Without `trustHost` + * // event path = https://example.com/auth/path/signin/github?callbackUrl=foo + * getHostValueForAuthjs(event, runtimeConfig, false) === 'https://your.domain/api/auth' + * + * // With `trustHost` + * // event path = https://example.com/auth/path/signin/github?callbackUrl=foo + * getHostValueForAuthjs(event, runtimeConfig, true) === 'https://example.com/api/auth' + * ``` + * + * @param event The H3 Event containing the request + * @param runtimeConfig Nuxt RuntimeConfig + * @param trustHostUserPreference Whether the host can be trusted. If `true`, base will be inferred from the request, otherwise the configured origin will be used. + * @returns {string} Value formatted for usage with Authjs + * @throws {Error} When server origin was incorrectly configured or when URL building failed + */ +export function getHostValueForAuthjs( + event: H3Event, + runtimeConfig: RuntimeConfig, + trustHostUserPreference: boolean, + isProduction: boolean +): string { + return getServerBaseUrl(runtimeConfig, true, trustHostUserPreference, isProduction, event) +} + +/** + * Get the full base URL including Origin and pathname + * + * @param runtimeConfig Nuxt Runtime Config + * @param includePath Whether function should output just Origin or the full URL + * @param trustHostUserPreference Whether the host can be trusted. If `true`, base will be inferred from the request, otherwise the configured origin will be used. + * @param isProduction Whether app is running in production mode. In non-production mode function will try to infer the result from the passed event. + * @param event The H3 Event for inferring the result (optional) + * @throws {Error} When the calculated result did not include a valid Origin, e.g. it will throw for the result of `/api/auth`, but will succeed for `https://example.com/api/auth` + */ +export function getServerBaseUrl( + runtimeConfig: RuntimeConfig, + includePath: boolean, + trustHostUserPreference: boolean, + isProduction: boolean, + event?: H3Event, +): string { + // Prio 1: Environment variable + // Prio 2: Static configuration + + // Resolve the value from runtime config/env. + // If the returned value has protocol and host, it is considered valid. + const baseURL = resolveApiBaseURL(runtimeConfig, false) + const parsed = parseURL(baseURL) + if (parsed.protocol && parsed.host) { + const base = `${parsed.protocol}//${parsed.host}` + return includePath + ? `${base}${parsed.pathname}${parsed.search || ''}${parsed.hash || ''}` + : base + } + + // Prio 3: Try to infer the origin if we're not in production or if user trusts host + if (event && (!isProduction || trustHostUserPreference)) { + const requestUrl = getRequestURL(event, { + xForwardedHost: trustHostUserPreference, + xForwardedProto: trustHostUserPreference || undefined + }) + + if (!includePath) { + return requestUrl.origin + } + + // When path is needed, use the preconfigured base path instead of parsing request's pathname + const basePath = withLeadingSlash(parsed.pathname) + requestUrl.pathname = basePath + + return requestUrl.href + } + + throw new Error(ERROR_MESSAGES.NO_ORIGIN) +} diff --git a/src/runtime/server/services/utils.ts b/src/runtime/server/services/utils.ts deleted file mode 100644 index 7cbe5446..00000000 --- a/src/runtime/server/services/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { H3Event } from 'h3' -import getURL from 'requrl' -import { parseURL } from 'ufo' -import { isProduction } from '../../helpers' -import { resolveApiBaseURL } from '../../utils/url' -import { ERROR_MESSAGES } from './errors' -import { useRuntimeConfig } from '#imports' - -/** - * Get `origin` and fallback to `x-forwarded-host` or `host` headers if not in production. - */ -export function getServerOrigin(event?: H3Event): string { - const runtimeConfig = useRuntimeConfig() - - // Prio 1: Environment variable - // Prio 2: Static configuration - - // Resolve the value from runtime config/env. - // If the returned value has protocol and host, it is considered valid. - const baseURL = resolveApiBaseURL(runtimeConfig, false) - const parsed = parseURL(baseURL) - if (parsed.protocol && parsed.host) { - return `${parsed.protocol}//${parsed.host}` - } - - // Prio 3: Try to infer the origin if we're not in production - if (event && !isProduction) { - return getURL(event.node.req, false) - } - - throw new Error(ERROR_MESSAGES.NO_ORIGIN) -} diff --git a/src/runtime/utils/callbackUrl.ts b/src/runtime/utils/callbackUrl.ts new file mode 100644 index 00000000..0acdad41 --- /dev/null +++ b/src/runtime/utils/callbackUrl.ts @@ -0,0 +1,116 @@ +import { getRequestURLWN } from '../composables/common/getRequestURL' +import type { RouteMiddleware } from '#app' +import { callWithNuxt, useNuxtApp, useRouter } from '#app' + +/** Slimmed down auth runtime config for `determineCallbackUrl` */ +interface AuthRuntimeConfigForCallbackUrl { + globalAppMiddleware: { + addDefaultCallbackUrl?: string | boolean + } | boolean +} + +// Overloads for better typing +export async function determineCallbackUrl( + authConfig: AuthRuntimeConfigForCallbackUrl, + userCallbackUrl: string | undefined, + inferFromRequest: true +): Promise +export async function determineCallbackUrl( + authConfig: AuthRuntimeConfigForCallbackUrl, + userCallbackUrl: string | undefined, + inferFromRequest?: false | undefined +): Promise + +/** + * Determines the desired callback url based on the users desires. Either: + * - uses a hardcoded path the user provided, + * - determines the callback based on the target the user wanted to reach + * + * @param authConfig Authentication runtime module config + * @param userCallbackUrl Callback URL provided by a user, e.g. as options to `signIn` + * @param inferFromRequest When `true`, will always do inference. + * When `false`, will never infer. + * When `undefined`, inference depends on `addDefaultCallbackUrl` + */ +export async function determineCallbackUrl( + authConfig: AuthRuntimeConfigForCallbackUrl, + userCallbackUrl: string | undefined, + inferFromRequest?: boolean | undefined +): Promise { + // Priority 1: User setting + if (userCallbackUrl) { + return await normalizeCallbackUrl(userCallbackUrl) + } + + // Priority 2: `addDefaultCallbackUrl` + const authConfigCallbackUrl = typeof authConfig.globalAppMiddleware === 'object' + ? authConfig.globalAppMiddleware.addDefaultCallbackUrl + : undefined + + // If a string value was set, always callback to it + if (typeof authConfigCallbackUrl === 'string') { + return await normalizeCallbackUrl(authConfigCallbackUrl) + } + + // Priority 3: Infer callback URL from the request + const shouldInferFromRequest = inferFromRequest !== false + && ( + inferFromRequest === true + || authConfigCallbackUrl === true + || (authConfigCallbackUrl === undefined && authConfig.globalAppMiddleware === true) + ) + + if (shouldInferFromRequest) { + const nuxt = useNuxtApp() + return getRequestURLWN(nuxt) + } +} + +// Avoid importing from `vue-router` directly +type RouteLocationNormalized = Parameters[0] + +/** + * Determines the correct callback URL for usage with Nuxt Route Middleware. + * The difference with a plain `determineCallbackUrl` is that this function produces + * non-normalized URLs. It is done because the result is being passed to `signIn` which does normalization. + * + * @param authConfig NuxtAuth module config (`runtimeConfig.public.auth`) + * @param middlewareTo The `to` parameter of NuxtRouteMiddleware + */ +export function determineCallbackUrlForRouteMiddleware( + authConfig: AuthRuntimeConfigForCallbackUrl, + middlewareTo: RouteLocationNormalized +): string | undefined { + const authConfigCallbackUrl = typeof authConfig.globalAppMiddleware === 'object' + ? authConfig.globalAppMiddleware.addDefaultCallbackUrl + : undefined + + // Priority 1: If a string value `addDefaultCallbackUrl` was set, always callback to it + if (typeof authConfigCallbackUrl === 'string') { + return authConfigCallbackUrl + } + + // Priority 2: `addDefaultCallbackUrl: true` or `globalAppMiddleware: true` + if ( + authConfigCallbackUrl === true + || (authConfigCallbackUrl === undefined && authConfig.globalAppMiddleware === true) + ) { + return middlewareTo.fullPath + } +} + +/** + * Normalizes the path by taking `app.baseURL` into account + * + * @see https://github.com/sidebase/nuxt-auth/issues/990#issuecomment-2630143443 + */ +async function normalizeCallbackUrl(rawCallbackUrl: string) { + const nuxt = useNuxtApp() + const router = await callWithNuxt(nuxt, useRouter) + + const resolvedUserRoute = router.resolve(rawCallbackUrl) + // no check for `resolvedUserRoute.matched` - prefer to show default 404 instead + + // Use `href` to include any possible `app.baseURL` + return resolvedUserRoute.href +} diff --git a/src/runtime/utils/url.ts b/src/runtime/utils/url.ts index 847a3d73..4476d0d8 100644 --- a/src/runtime/utils/url.ts +++ b/src/runtime/utils/url.ts @@ -1,7 +1,7 @@ import { joinURL, parseURL, withLeadingSlash } from 'ufo' // Slimmed down type to allow easy unit testing -interface RuntimeConfig { +export interface RuntimeConfig { public: { auth: { baseURL: string @@ -55,47 +55,6 @@ export function resolveApiBaseURL(runtimeConfig: RuntimeConfig, returnOnlyPathna return baseURL } -/** Slimmed down auth runtime config for `determineCallbackUrl` */ -interface AuthRuntimeConfigForCallbackUrl { - globalAppMiddleware: { - addDefaultCallbackUrl?: string | boolean - } | boolean -} - -/** - * Determines the desired callback url based on the users desires. Either: - * - uses a hardcoded path the user provided, - * - determines the callback based on the target the user wanted to reach - * - * @param authConfig Authentication runtime module config - * @param getOriginalTargetPath Function that returns the original location the user wanted to reach - */ -export function determineCallbackUrl>( - authConfig: AuthRuntimeConfigForCallbackUrl, - getOriginalTargetPath: () => T -): T | string | undefined { - const authConfigCallbackUrl = typeof authConfig.globalAppMiddleware === 'object' - ? authConfig.globalAppMiddleware.addDefaultCallbackUrl - : undefined - - if (typeof authConfigCallbackUrl !== 'undefined') { - // If string was set, always callback to that string - if (typeof authConfigCallbackUrl === 'string') { - return authConfigCallbackUrl - } - - // If boolean was set, set to current path if set to true - if (typeof authConfigCallbackUrl === 'boolean') { - if (authConfigCallbackUrl) { - return getOriginalTargetPath() - } - } - } - else if (authConfig.globalAppMiddleware === true) { - return getOriginalTargetPath() - } -} - /** * Naively checks if a URL is external or not by comparing against its protocol. *