From 578800588784f32275f3980a685987fef5ef71da Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 12 Dec 2024 13:08:17 +0100 Subject: [PATCH 1/3] feat(#797, #878): set `baseURL` via environment variables and improve internal url detection (#913) --- .github/workflows/ci.yaml | 7 +- .github/workflows/deploy-docs.yml | 2 +- .github/workflows/pkg.pr.new.yml | 2 +- package.json | 4 +- playground-authjs/nuxt.config.ts | 2 +- pnpm-lock.yaml | 117 ++++++++- src/module.ts | 43 ++-- src/runtime/composables/authjs/useAuth.ts | 23 +- .../authjs/utils/navigateToAuthPage.ts | 53 ++++ .../composables/common/getRequestURL.ts | 10 + src/runtime/composables/commonAuthState.ts | 21 +- src/runtime/composables/local/useAuth.ts | 4 +- src/runtime/composables/local/useAuthState.ts | 5 +- .../local/utils/token.ts} | 2 +- src/runtime/helpers.ts | 16 -- src/runtime/middleware/auth.ts | 5 +- src/runtime/plugin.ts | 32 ++- .../server/services/authjs/nuxtAuthHandler.ts | 8 +- src/runtime/server/services/utils.ts | 38 +-- src/runtime/types.ts | 27 +- src/runtime/utils/callWithNuxt.ts | 9 - src/runtime/utils/extractFromRuntimeConfig.ts | 16 ++ src/runtime/utils/fetch.ts | 35 ++- src/runtime/utils/url.ts | 108 ++++---- tests/authjs.url.spec.ts | 231 ++++++++++++++++++ tests/local.url.spec.ts | 224 +++++++++++++++++ vitest.config.ts | 7 + 27 files changed, 845 insertions(+), 206 deletions(-) create mode 100644 src/runtime/composables/authjs/utils/navigateToAuthPage.ts create mode 100644 src/runtime/composables/common/getRequestURL.ts rename src/runtime/{utils/local.ts => composables/local/utils/token.ts} (79%) delete mode 100644 src/runtime/utils/callWithNuxt.ts create mode 100644 src/runtime/utils/extractFromRuntimeConfig.ts create mode 100644 tests/authjs.url.spec.ts create mode 100644 tests/local.url.spec.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 026adb55..ce96f8de 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,7 @@ on: branches: [main] env: - NODE_VER: 22.5 + NODE_VER: 22.11 CI: true jobs: @@ -37,6 +37,9 @@ jobs: # Check linting and typing - run: pnpm lint - run: pnpm typecheck + + # Run unit tests + - run: pnpm test:unit # Check building - run: pnpm build @@ -131,5 +134,5 @@ jobs: # start prod-app and curl from it - run: "timeout 60 pnpm start & (sleep 45 && curl --fail localhost:$PORT)" env: - AUTH_ORIGIN: "http://localhost:3001" + AUTH_ORIGIN: "http://localhost:3001/api/auth" PORT: 3001 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 22dcdc09..796168d1 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: env: - NODE_VER: 22.5 + NODE_VER: 22.11 CI: true # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index b18c86b2..f89ccab1 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -8,7 +8,7 @@ on: pull_request: env: - NODE_VER: 22.5 + NODE_VER: 22.11 jobs: build: diff --git a/package.json b/package.json index 811df390..4316e5ad 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "dev:prepare": "nuxt-module-build build --stub", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs" + "docs:preview": "vitepress preview docs", + "test:unit": "vitest" }, "dependencies": { "@nuxt/kit": "^3.12.4", @@ -61,6 +62,7 @@ "ts-essentials": "^9.4.2", "typescript": "^5.5.4", "vitepress": "^1.3.1", + "vitest": "^1.6.0", "vue-tsc": "^2.0.29" }, "packageManager": "pnpm@9.6.0+sha512.38dc6fba8dba35b39340b9700112c2fe1e12f10b17134715a4aa98ccf7bb035e76fd981cf0bb384dfa98f8d6af5481c2bef2f4266a24bfa20c34eb7147ce0b5e" diff --git a/playground-authjs/nuxt.config.ts b/playground-authjs/nuxt.config.ts index b242884f..b48a7c7a 100644 --- a/playground-authjs/nuxt.config.ts +++ b/playground-authjs/nuxt.config.ts @@ -8,7 +8,7 @@ export default defineNuxtConfig({ globalAppMiddleware: { isEnabled: true }, - baseURL: `http://localhost:${process.env.PORT || 3000}` + baseURL: `http://localhost:${process.env.PORT || 3000}/api/auth` }, routeRules: { '/with-caching': { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f80d620..e88bf671 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: vitepress: specifier: ^1.3.1 version: 1.3.1(@algolia/client-search@4.24.0)(@types/node@18.19.42)(postcss@8.4.40)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(search-insights@2.14.0)(terser@5.30.3)(typescript@5.5.4) + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@18.19.42)(terser@5.30.3) vue-tsc: specifier: ^2.0.29 version: 2.0.29(typescript@5.5.4) @@ -80,7 +83,7 @@ importers: devDependencies: nuxt: specifier: ^3.12.4 - version: 3.12.4(@parcel/watcher@2.4.1)(@types/node@20.12.7)(encoding@0.1.13)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.4)(optionator@0.9.3)(rollup@4.19.2)(terser@5.30.3)(typescript@5.5.4)(vite@5.3.3(@types/node@18.19.42)(terser@5.30.3))(vue-tsc@2.0.29(typescript@5.5.4)) + version: 3.12.4(@parcel/watcher@2.4.1)(@types/node@20.12.7)(encoding@0.1.13)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.4)(optionator@0.9.3)(rollup@4.19.2)(terser@5.30.3)(typescript@5.5.4)(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3))(vue-tsc@2.0.29(typescript@5.5.4)) typescript: specifier: ^5.5.4 version: 5.5.4 @@ -7114,6 +7117,17 @@ snapshots: - rollup - supports-color + '@nuxt/devtools-kit@1.3.9(magicast@0.3.4)(rollup@4.19.2)(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3))': + dependencies: + '@nuxt/kit': 3.12.4(magicast@0.3.4)(rollup@4.19.2) + '@nuxt/schema': 3.12.4(rollup@4.19.2) + execa: 7.2.0 + vite: 5.3.5(@types/node@20.12.7)(terser@5.30.3) + transitivePeerDependencies: + - magicast + - rollup + - supports-color + '@nuxt/devtools-wizard@1.3.9': dependencies: consola: 3.2.3 @@ -7219,6 +7233,52 @@ snapshots: - supports-color - utf-8-validate + '@nuxt/devtools@1.3.9(rollup@4.19.2)(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3))': + dependencies: + '@antfu/utils': 0.7.10 + '@nuxt/devtools-kit': 1.3.9(magicast@0.3.4)(rollup@4.19.2)(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)) + '@nuxt/devtools-wizard': 1.3.9 + '@nuxt/kit': 3.12.4(magicast@0.3.4)(rollup@4.19.2) + '@vue/devtools-core': 7.3.3(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)) + '@vue/devtools-kit': 7.3.3 + birpc: 0.2.17 + consola: 3.2.3 + cronstrue: 2.50.0 + destr: 2.0.3 + error-stack-parser-es: 0.1.5 + execa: 7.2.0 + fast-glob: 3.3.2 + fast-npm-meta: 0.1.1 + flatted: 3.3.1 + get-port-please: 3.1.2 + hookable: 5.5.3 + image-meta: 0.2.1 + is-installed-globally: 1.0.0 + launch-editor: 2.8.0 + local-pkg: 0.5.0 + magicast: 0.3.4 + nypm: 0.3.9 + ohash: 1.1.3 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.1.3 + rc9: 2.1.2 + scule: 1.3.0 + semver: 7.6.3 + simple-git: 3.25.0 + sirv: 2.0.4 + unimport: 3.9.1(rollup@4.19.2) + vite: 5.3.5(@types/node@20.12.7)(terser@5.30.3) + vite-plugin-inspect: 0.8.5(@nuxt/kit@3.12.4(magicast@0.3.4)(rollup@4.19.2))(rollup@4.19.2)(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)) + vite-plugin-vue-inspector: 5.1.3(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)) + which: 3.0.1 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - rollup + - supports-color + - utf-8-validate + '@nuxt/kit@3.12.4(magicast@0.3.4)(rollup@4.19.2)': dependencies: '@nuxt/schema': 3.12.4(rollup@4.19.2) @@ -8477,6 +8537,17 @@ snapshots: transitivePeerDependencies: - vite + '@vue/devtools-core@7.3.3(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3))': + dependencies: + '@vue/devtools-kit': 7.3.5 + '@vue/devtools-shared': 7.3.7 + mitt: 3.0.1 + nanoid: 3.3.7 + pathe: 1.1.2 + vite-hot-client: 0.2.3(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)) + transitivePeerDependencies: + - vite + '@vue/devtools-kit@7.3.3': dependencies: '@vue/devtools-shared': 7.3.7 @@ -11340,10 +11411,10 @@ snapshots: - vue-tsc - xml2js - nuxt@3.12.4(@parcel/watcher@2.4.1)(@types/node@20.12.7)(encoding@0.1.13)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.4)(optionator@0.9.3)(rollup@4.19.2)(terser@5.30.3)(typescript@5.5.4)(vite@5.3.3(@types/node@18.19.42)(terser@5.30.3))(vue-tsc@2.0.29(typescript@5.5.4)): + nuxt@3.12.4(@parcel/watcher@2.4.1)(@types/node@20.12.7)(encoding@0.1.13)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.4)(optionator@0.9.3)(rollup@4.19.2)(terser@5.30.3)(typescript@5.5.4)(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3))(vue-tsc@2.0.29(typescript@5.5.4)): dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 1.3.9(rollup@4.19.2)(vite@5.3.3(@types/node@18.19.42)(terser@5.30.3)) + '@nuxt/devtools': 1.3.9(rollup@4.19.2)(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)) '@nuxt/kit': 3.12.4(magicast@0.3.4)(rollup@4.19.2) '@nuxt/schema': 3.12.4(rollup@4.19.2) '@nuxt/telemetry': 2.5.4(magicast@0.3.4)(rollup@4.19.2) @@ -12795,6 +12866,10 @@ snapshots: dependencies: vite: 5.3.5(@types/node@18.19.45)(terser@5.30.3) + vite-hot-client@0.2.3(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)): + dependencies: + vite: 5.3.5(@types/node@20.12.7)(terser@5.30.3) + vite-node@1.6.0(@types/node@18.19.42)(terser@5.30.3): dependencies: cac: 6.7.14 @@ -12811,7 +12886,6 @@ snapshots: - sugarss - supports-color - terser - optional: true vite-node@1.6.0(@types/node@18.19.45)(terser@5.30.3): dependencies: @@ -12986,6 +13060,24 @@ snapshots: - rollup - supports-color + vite-plugin-inspect@0.8.5(@nuxt/kit@3.12.4(magicast@0.3.4)(rollup@4.19.2))(rollup@4.19.2)(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.1.0(rollup@4.19.2) + debug: 4.3.6 + error-stack-parser-es: 0.1.5 + fs-extra: 11.2.0 + open: 10.1.0 + perfect-debounce: 1.0.0 + picocolors: 1.0.1 + sirv: 2.0.4 + vite: 5.3.5(@types/node@20.12.7)(terser@5.30.3) + optionalDependencies: + '@nuxt/kit': 3.12.4(magicast@0.3.4)(rollup@4.19.2) + transitivePeerDependencies: + - rollup + - supports-color + vite-plugin-vue-inspector@5.1.3(vite@5.3.3(@types/node@18.19.42)(terser@5.30.3)): dependencies: '@babel/core': 7.25.2 @@ -13016,6 +13108,21 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-vue-inspector@5.1.3(vite@5.3.5(@types/node@20.12.7)(terser@5.30.3)): + dependencies: + '@babel/core': 7.25.2 + '@babel/plugin-proposal-decorators': 7.24.7(@babel/core@7.25.2) + '@babel/plugin-syntax-import-attributes': 7.24.7(@babel/core@7.25.2) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.25.2) + '@babel/plugin-transform-typescript': 7.25.2(@babel/core@7.25.2) + '@vue/babel-plugin-jsx': 1.2.2(@babel/core@7.25.2) + '@vue/compiler-dom': 3.4.35 + kolorist: 1.8.0 + magic-string: 0.30.11 + vite: 5.3.5(@types/node@20.12.7)(terser@5.30.3) + transitivePeerDependencies: + - supports-color + vite@5.2.9(@types/node@18.19.42)(terser@5.30.3): dependencies: esbuild: 0.20.2 @@ -13025,7 +13132,6 @@ snapshots: '@types/node': 18.19.42 fsevents: 2.3.3 terser: 5.30.3 - optional: true vite@5.2.9(@types/node@18.19.45)(terser@5.30.3): dependencies: @@ -13179,7 +13285,6 @@ snapshots: - sugarss - supports-color - terser - optional: true vitest@1.6.0(@types/node@18.19.45)(terser@5.30.3): dependencies: diff --git a/src/module.ts b/src/module.ts index 16d727fc..fc8320b3 100644 --- a/src/module.ts +++ b/src/module.ts @@ -11,11 +11,10 @@ import { useLogger } from '@nuxt/kit' import { defu } from 'defu' -import { joinURL } from 'ufo' import { genInterface } from 'knitwork' import type { DeepRequired } from 'ts-essentials' import type { NuxtModule } from 'nuxt/schema' -import { getOriginAndPathnameFromURL, isProduction } from './runtime/helpers' +import { isProduction } from './runtime/helpers' import type { AuthProviders, ModuleOptions, @@ -26,6 +25,8 @@ import type { const topLevelDefaults = { isEnabled: true, + baseURL: '/api/auth', + disableInternalRouting: false as boolean, disableServerSideAuth: false, originEnvKey: 'AUTH_ORIGIN', sessionRefresh: { @@ -108,26 +109,16 @@ export default defineNuxtModule({ const logger = useLogger(PACKAGE_NAME) // 0. Assemble all options - const { origin, pathname = '/api/auth' } = getOriginAndPathnameFromURL( - userOptions.baseURL ?? '' - ) const selectedProvider = userOptions.provider?.type ?? 'authjs' - const options = { - ...defu(userOptions, topLevelDefaults, { - computed: { - origin, - pathname, - fullBaseUrl: joinURL(origin ?? '', pathname) - } - }), + const options = defu({ // We use `as` to infer backend types correctly for runtime-usage (everything is set, although for user everything was optional) provider: defu( userOptions.provider, defaultsByBackend[selectedProvider] ) as DeepRequired - } + }, userOptions, topLevelDefaults) // 1. Check if module should be enabled at all if (!options.isEnabled) { @@ -137,15 +128,23 @@ export default defineNuxtModule({ logger.info('`nuxt-auth` setup starting') - // 2. Set up runtime configuration + // 2.1. Disable internal routing for `local` provider when not specified otherwise + // https://github.com/sidebase/nuxt-auth/issues/797 + if (userOptions.disableInternalRouting === undefined && selectedProvider === 'local') { + options.disableInternalRouting = true + } + + // 2.2. Set up runtime configuration if (!isProduction) { - const authjsAddition - = selectedProvider === 'authjs' - ? ', ensure that `NuxtAuthHandler({ ... })` is there, see https://sidebase.io/nuxt-auth/configuration/nuxt-auth-handler' - : '' - logger.info( - `Selected provider: ${selectedProvider}. Auth API location is \`${options.computed.fullBaseUrl}\`${authjsAddition}` - ) + const loggerMessages = [ + `Selected provider: ${selectedProvider}.`, + `Auth API location is \`${options.baseURL}\`, if you would like to change this, see https://auth.sidebase.io/guide/application-side/configuration#baseurl.` + ] + if (selectedProvider === 'authjs') { + loggerMessages.push('Ensure that the `NuxtAuthHandler({ ... })` is there, see https://auth.sidebase.io/guide/authjs/nuxt-auth-handler') + } + + logger.info(loggerMessages.join(' ')) } nuxt.options.runtimeConfig = nuxt.options.runtimeConfig || { public: {} } diff --git a/src/runtime/composables/authjs/useAuth.ts b/src/runtime/composables/authjs/useAuth.ts index 78ceb585..85e1279d 100644 --- a/src/runtime/composables/authjs/useAuth.ts +++ b/src/runtime/composables/authjs/useAuth.ts @@ -2,13 +2,14 @@ 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 } from '../../utils/url' -import { getRequestURLWN, joinPathToApiURLWN, makeCWN, navigateToAuthPageWN } from '../../utils/callWithNuxt' +import { determineCallbackUrl, 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 type { SessionData } from './useAuthState' +import { navigateToAuthPageWN } from './utils/navigateToAuthPage' import type { NuxtApp } from '#app/nuxt' import { callWithNuxt } from '#app/nuxt' import { createError, useAuthState, useNuxtApp, useRequestHeaders, useRuntimeConfig } from '#imports' @@ -49,7 +50,9 @@ async function getCsrfToken() { const headers = await getRequestCookies(nuxt) return _fetch<{ csrfToken: string }>(nuxt, '/csrf', { headers }).then(response => response.csrfToken) } -const getCsrfTokenWithNuxt = makeCWN(getCsrfToken) +function getCsrfTokenWithNuxt(nuxt: NuxtApp) { + return callWithNuxt(nuxt, getCsrfToken) +} /** * Trigger a sign in flow for the passed `provider`. If no provider is given the sign in page for all providers will be shown. @@ -61,17 +64,16 @@ const getCsrfTokenWithNuxt = makeCWN(getCsrfToken) type SignInResult = void | { error: string | null, status: number, ok: boolean, url: any } const signIn: SignInFunc = async (provider, options, authorizationParams) => { const nuxt = useNuxtApp() + const runtimeConfig = await callWithNuxt(nuxt, useRuntimeConfig) // 1. Lead to error page if no providers are available const configuredProviders = await getProviders() if (!configuredProviders) { - const errorUrl = await joinPathToApiURLWN(nuxt, 'error') + const errorUrl = resolveApiUrlPath('error', runtimeConfig) return navigateToAuthPageWN(nuxt, errorUrl) } // 2. If no `provider` was given, either use the configured `defaultProvider` or `undefined` (leading to a forward to the `/login` page with all providers) - const runtimeConfig = await callWithNuxt(nuxt, useRuntimeConfig) - const backendConfig = useTypedBackendConfig(runtimeConfig, 'authjs') if (typeof provider === 'undefined') { // NOTE: `provider` might be an empty string @@ -87,7 +89,7 @@ const signIn: SignInFunc = async (provider, op callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, () => getRequestURLWN(nuxt)) } - const signinUrl = await joinPathToApiURLWN(nuxt, 'signin') + const signinUrl = resolveApiUrlPath('signin', runtimeConfig) const queryParams = callbackUrl ? `?${new URLSearchParams({ callbackUrl })}` : '' const hrefSignInAllProviderPage = `${signinUrl}${queryParams}` @@ -140,7 +142,6 @@ const signIn: SignInFunc = async (provider, op // At this point the request succeeded (i.e., it went through) const error = new URL(data.url).searchParams.get('error') - // eslint-disable-next-line ts/no-use-before-define await getSessionWithNuxt(nuxt) return { @@ -163,7 +164,7 @@ function getProviders() { * * @param getSessionOptions - Options for getting the session, e.g., set `required: true` to enforce that a session _must_ exist, the user will be directed to a login page otherwise. */ -async function getSession(getSessionOptions?: GetSessionOptions): Promise { +async function getSession(getSessionOptions?: GetSessionOptions): Promise { const nuxt = useNuxtApp() const callbackUrlFallback = await getRequestURLWN(nuxt) @@ -222,7 +223,9 @@ async function getSession(getSessionOptions?: GetSessionOptions): Promise { + const encodedLoc = href.replace(/"/g, '%22') + const encodedHeader = new URL(href).toString() + nuxtApp.ssrContext!._renderResponse = { + statusCode: sanitizeStatusCode(302, 302), + body: ``, + headers: { location: encodedHeader }, + } + abortNavigation() + }) + } + } + + window.location.href = href + // If href contains a hash, the browser does not reload the page. We reload manually. + if (href.includes('#')) { + window.location.reload() + } + + // TODO: Sadly, we cannot directly import types from `vue-router` as it leads to build failures. Typing the router about should help us to avoid manually typing `route` below + const router = nuxtApp.$router as { push: (href: string) => void } + + // Wait for the `window.location.href` navigation from above to complete to avoid showing content. If that doesn't work fast enough, delegate navigation back to the `vue-router` (risking a vue-router 404 warning in the console, but still avoiding content-flashes of the protected target page) + const waitForNavigationWithFallbackToRouter = new Promise(resolve => setTimeout(resolve, 60 * 1000)) + .then(() => router.push(href)) + + return waitForNavigationWithFallbackToRouter as Promise +} diff --git a/src/runtime/composables/common/getRequestURL.ts b/src/runtime/composables/common/getRequestURL.ts new file mode 100644 index 00000000..413a32fc --- /dev/null +++ b/src/runtime/composables/common/getRequestURL.ts @@ -0,0 +1,10 @@ +import getURL from 'requrl' +import { type NuxtApp, callWithNuxt, useRequestEvent } from '#app' + +export function getRequestURL(includePath = true) { + return getURL(useRequestEvent()?.node.req, includePath) +} + +export function getRequestURLWN(nuxt: NuxtApp) { + return callWithNuxt(nuxt, getRequestURL) +} diff --git a/src/runtime/composables/commonAuthState.ts b/src/runtime/composables/commonAuthState.ts index 19c53d67..5e42b310 100644 --- a/src/runtime/composables/commonAuthState.ts +++ b/src/runtime/composables/commonAuthState.ts @@ -1,8 +1,6 @@ import { computed } from 'vue' -import getURL from 'requrl' -import { joinURL } from 'ufo' import type { SessionLastRefreshedAt, SessionStatus } from '../types' -import { useRequestEvent, useRuntimeConfig, useState } from '#imports' +import { useState } from '#imports' export function makeCommonAuthState() { const data = useState('auth:data', () => undefined) @@ -30,27 +28,10 @@ export function makeCommonAuthState() { return 'unauthenticated' }) - // Determine base url of app - let baseURL - const { origin, pathname, fullBaseUrl } = useRuntimeConfig().public.auth.computed - if (origin) { - // Case 1: An origin was supplied by the developer in the runtime-config. Use it by returning the already assembled full base url that contains it - baseURL = fullBaseUrl - } - else { - // Case 2: An origin was not supplied, we determine it from the request - const determinedOrigin = getURL(useRequestEvent()?.node.req, false) - baseURL = joinURL(determinedOrigin, pathname) - } - return { data, loading, lastRefreshedAt, status, - _internal: { - baseURL, - pathname - } } } diff --git a/src/runtime/composables/local/useAuth.ts b/src/runtime/composables/local/useAuth.ts index 60ce39ce..db3e4660 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 { getRequestURLWN } from '../../utils/callWithNuxt' import { determineCallbackUrl } from '../../utils/url' -import { formatToken } from '../../utils/local' +import { getRequestURLWN } from '../common/getRequestURL' +import { formatToken } from './utils/token' import { type UseAuthStateReturn, useAuthState } from './useAuthState' import { callWithNuxt } from '#app/nuxt' // @ts-expect-error - #auth not defined diff --git a/src/runtime/composables/local/useAuthState.ts b/src/runtime/composables/local/useAuthState.ts index 06ffe561..5dcb0c73 100644 --- a/src/runtime/composables/local/useAuthState.ts +++ b/src/runtime/composables/local/useAuthState.ts @@ -2,7 +2,7 @@ import { type ComputedRef, computed, getCurrentInstance, watch } from 'vue' import type { CommonUseAuthStateReturn } from '../../types' import { makeCommonAuthState } from '../commonAuthState' import { useTypedBackendConfig } from '../../helpers' -import { formatToken } from '../../utils/local' +import { formatToken } from './utils/token' import type { CookieRef } from '#app' import { onMounted, useCookie, useRuntimeConfig, useState } from '#imports' // @ts-expect-error - #auth not defined @@ -22,8 +22,6 @@ export interface UseAuthStateReturn extends CommonUseAuthStateReturn void clearToken: () => void _internal: { - baseURL: string - pathname: string rawTokenCookie: CookieRef } } @@ -108,7 +106,6 @@ export function useAuthState(): UseAuthStateReturn { setToken, clearToken, _internal: { - ...commonAuthState._internal, rawTokenCookie: _rawTokenCookie } } diff --git a/src/runtime/utils/local.ts b/src/runtime/composables/local/utils/token.ts similarity index 79% rename from src/runtime/utils/local.ts rename to src/runtime/composables/local/utils/token.ts index c4dc3ab1..c4d2652b 100644 --- a/src/runtime/utils/local.ts +++ b/src/runtime/composables/local/utils/token.ts @@ -1,4 +1,4 @@ -import type { ProviderLocalResolvedConfig } from '../helpers' +import type { ProviderLocalResolvedConfig } from '../../../helpers' export function formatToken(token: string | null | undefined, config: ProviderLocalResolvedConfig): string | null { if (token === null || token === undefined) { diff --git a/src/runtime/helpers.ts b/src/runtime/helpers.ts index 123c0b96..e5c21df7 100644 --- a/src/runtime/helpers.ts +++ b/src/runtime/helpers.ts @@ -1,26 +1,10 @@ // TODO: This should be merged into `./utils` -import { parseURL } from 'ufo' import type { DeepRequired } from 'ts-essentials' import type { ProviderAuthjs, ProviderLocal, SupportedAuthProviders } from './types' import type { useRuntimeConfig } from '#imports' export const isProduction = process.env.NODE_ENV === 'production' -export function getOriginAndPathnameFromURL(url: string) { - const { protocol, host, pathname } = parseURL(url) - - let origin - if (host && protocol) { - origin = `${protocol}//${host}` - } - - const pathname_ = pathname.length > 0 ? pathname : undefined - return { - origin, - pathname: pathname_ - } -} - // We use `DeepRequired` here because options are actually enriched using `defu` // but due to a build error we can't use `DeepRequired` inside runtime config definition. type RuntimeConfig = ReturnType diff --git a/src/runtime/middleware/auth.ts b/src/runtime/middleware/auth.ts index b1cb4992..094ed2d1 100644 --- a/src/runtime/middleware/auth.ts +++ b/src/runtime/middleware/auth.ts @@ -1,4 +1,3 @@ -import type { navigateToAuthPages } from '../utils/url' import { determineCallbackUrl } from '../utils/url' import { isProduction } from '../helpers' import { defineNuxtRouteMiddleware, navigateTo, useAuth, useRuntimeConfig } from '#imports' @@ -74,7 +73,7 @@ export default defineNuxtRouteMiddleware((to) => { * We do not want to enforce protection on `404` pages (unless the user opts out of it by setting `allow404WithoutAuth: false`). * * This is to: - * - improve UX and DX: Having to log-in to see a `404` is not pleasent, + * - improve UX and DX: Having to log-in to see a `404` is not pleasant, * - avoid the `Error [ERR_HTTP_HEADERS_SENT]`-error that occurs when we redirect to the sign-in page when the original to-page does not exist. Likely related to https://github.com/nuxt/framework/issues/9438 * */ @@ -91,7 +90,7 @@ export default defineNuxtRouteMiddleware((to) => { 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 - return signIn(undefined, signInOptions) as ReturnType + return signIn(undefined, signInOptions) as Promise } // Redirect path was provided diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 75952fad..c05e8e35 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -1,6 +1,8 @@ import { getHeader } from 'h3' import authMiddleware from './middleware/auth' import { getNitroRouteRules } from './utils/kit' +import { FetchConfigurationError } from './utils/fetch' +import { resolveApiBaseURL } from './utils/url' import { _refreshHandler, addRouteMiddleware, defineNuxtPlugin, useAuth, useAuthState, useRuntimeConfig } from '#imports' export default defineNuxtPlugin(async (nuxtApp) => { @@ -9,10 +11,18 @@ export default defineNuxtPlugin(async (nuxtApp) => { const { getSession } = useAuth() // use runtimeConfig - const runtimeConfig = useRuntimeConfig().public.auth + const wholeRuntimeConfig = useRuntimeConfig() + const runtimeConfig = wholeRuntimeConfig.public.auth + const globalAppMiddleware = runtimeConfig.globalAppMiddleware const routeRules = import.meta.server ? getNitroRouteRules(nuxtApp._route.path) : {} + // Set the correct `baseURL` on the server, + // because the client would not have access to environment variables + if (import.meta.server) { + runtimeConfig.baseURL = resolveApiBaseURL(wholeRuntimeConfig) + } + // Skip auth if we're prerendering let nitroPrerender = false if (nuxtApp.ssrContext) { @@ -30,8 +40,23 @@ export default defineNuxtPlugin(async (nuxtApp) => { } // Only fetch session if it was not yet initialized server-side - if (typeof data.value === 'undefined' && !nitroPrerender && !disableServerSideAuth) { - await getSession() + const isErrorUrl = nuxtApp.ssrContext?.error === true + const requireAuthOnErrorPage = globalAppMiddleware === true || (typeof globalAppMiddleware === 'object' && globalAppMiddleware.allow404WithoutAuth) + const shouldFetchSession = typeof data.value === 'undefined' + && !nitroPrerender + && !disableServerSideAuth + && !(isErrorUrl && requireAuthOnErrorPage) + + if (shouldFetchSession) { + try { + await getSession() + } + catch (e) { + // Do not throw the configuration error as it can lead to infinite recursion + if (!(e instanceof FetchConfigurationError)) { + throw e + } + } } // 2. Setup session maintanence, e.g., auto refreshing or refreshing on foux @@ -55,7 +80,6 @@ export default defineNuxtPlugin(async (nuxtApp) => { } // 3. Enable the middleware, either globally or as a named `auth` option - const { globalAppMiddleware } = useRuntimeConfig().public.auth if ( globalAppMiddleware === true || (typeof globalAppMiddleware === 'object' && globalAppMiddleware.isEnabled) diff --git a/src/runtime/server/services/authjs/nuxtAuthHandler.ts b/src/runtime/server/services/authjs/nuxtAuthHandler.ts index 82e845a6..7934054b 100644 --- a/src/runtime/server/services/authjs/nuxtAuthHandler.ts +++ b/src/runtime/server/services/authjs/nuxtAuthHandler.ts @@ -15,7 +15,7 @@ import { ERROR_MESSAGES } from '../errors' import { isNonEmptyObject } from '../../../utils/checkSessionResult' import { getServerOrigin } from '../utils' import { useTypedBackendConfig } from '../../../helpers' - +import { resolveApiBaseURL } from '../../../utils/url' import { useRuntimeConfig } from '#imports' let preparedAuthjsHandler: ((req: RequestInternal) => Promise) | undefined @@ -102,15 +102,15 @@ export function NuxtAuthHandler(nuxtAuthOptions?: AuthOptions) { /** Gets session on server-side */ export async function getServerSession(event: H3Event) { const runtimeConfig = useRuntimeConfig() - const authBasePath = runtimeConfig.public.auth.computed.pathname + const authBasePathname = resolveApiBaseURL(runtimeConfig, true) const trustHostUserPreference = useTypedBackendConfig(runtimeConfig, 'authjs').trustHost // avoid running auth middleware on auth middleware (see #186) - if (event.path && event.path.startsWith(authBasePath)) { + if (event.path && event.path.startsWith(authBasePathname)) { return null } - const sessionUrlPath = joinURL(authBasePath, '/session') + const sessionUrlPath = joinURL(authBasePathname, '/session') const headers = getHeaders(event) as HeadersInit if (!preparedAuthjsHandler) { // Edge-case: If no auth-endpoint was called yet, `preparedAuthHandler`-initialization was also not attempted as Nuxt lazily loads endpoints in production-mode. diff --git a/src/runtime/server/services/utils.ts b/src/runtime/server/services/utils.ts index a692e3ee..7cbe5446 100644 --- a/src/runtime/server/services/utils.ts +++ b/src/runtime/server/services/utils.ts @@ -1,7 +1,8 @@ import type { H3Event } from 'h3' import getURL from 'requrl' -import { camelCase } from 'scule' +import { parseURL } from 'ufo' import { isProduction } from '../../helpers' +import { resolveApiBaseURL } from '../../utils/url' import { ERROR_MESSAGES } from './errors' import { useRuntimeConfig } from '#imports' @@ -9,20 +10,17 @@ 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 config = useRuntimeConfig() + const runtimeConfig = useRuntimeConfig() // Prio 1: Environment variable - const envOriginKey = config.public.auth.originEnvKey - const envFromRuntimeConfig = extractFromRuntimeConfig(config, envOriginKey) - const envOrigin = envFromRuntimeConfig ?? process.env[envOriginKey] - if (envOrigin) { - return envOrigin - } - - // Prio 2: Computed origin - const runtimeConfigOrigin = config.public.auth.computed.origin - if (runtimeConfigOrigin) { - return runtimeConfigOrigin + // 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 @@ -32,17 +30,3 @@ export function getServerOrigin(event?: H3Event): string { throw new Error(ERROR_MESSAGES.NO_ORIGIN) } - -type RuntimeConfig = ReturnType - -function extractFromRuntimeConfig(config: RuntimeConfig, envVariableName: string): string | undefined { - let normalized = envVariableName.startsWith('NUXT_') - ? envVariableName.slice(5) - : envVariableName - normalized = camelCase(normalized, { normalize: true }) - - const extracted = config[normalized] - return typeof extracted === 'string' - ? extracted - : undefined -} diff --git a/src/runtime/types.ts b/src/runtime/types.ts index dc225b69..9b037855 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -424,6 +424,21 @@ export interface ModuleOptions { * Whether the module is enabled at all */ isEnabled?: boolean + /** + * Disables the Nuxt `$fetch` optimization. Do so when your auth logic is not handled by a Nuxt server (e.g. when using an external backend). + * + * Disabling the optimisation means that NuxtAuth will prefer calling `baseURL` + path instead of just path, + * which would often translate to an HTTP call. + * + * By default, this option is set to `false` for `authjs` provider. + * For `local` provider `disableInternalRouting` will default to `true` unless explicitly changed by user. + * + * ## Example + * With `disableInternalRouting: true` and `baseURL: 'https://example.com/api/auth'` your calls would be made to `https://example.com/api/auth` endpoints instead of `/api/auth`. + * + * @see https://nuxt.com/docs/api/utils/dollarfetch + */ + disableInternalRouting?: boolean /** * Forces your server to send a "loading" status on all requests, prompting the client to fetch on the client. If your website has caching, this prevents the server from caching someone's authentication status. * @@ -537,10 +552,6 @@ export interface CommonUseAuthStateReturn { loading: Ref lastRefreshedAt: Ref status: ComputedRef - _internal: { - baseURL: string - pathname: string - } } // Common `useAuth` method-types @@ -604,15 +615,11 @@ export type SignInFunc = ( export interface ModuleOptionsNormalized extends ModuleOptions { isEnabled: boolean + baseURL: string + disableInternalRouting: boolean // Cannot use `DeepRequired` here because it leads to build issues provider: Required> sessionRefresh: NonNullable globalAppMiddleware: NonNullable originEnvKey: string - - computed: { - origin: string | undefined - pathname: string - fullBaseUrl: string - } } diff --git a/src/runtime/utils/callWithNuxt.ts b/src/runtime/utils/callWithNuxt.ts deleted file mode 100644 index 6f601782..00000000 --- a/src/runtime/utils/callWithNuxt.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getRequestURL, joinPathToApiURL, navigateToAuthPages } from './url' -import type { NuxtApp } from '#app/nuxt' -import { callWithNuxt } from '#app/nuxt' - -export const navigateToAuthPageWN = (nuxt: NuxtApp, href: string) => callWithNuxt(nuxt, navigateToAuthPages, [href]) -export const getRequestURLWN = (nuxt: NuxtApp) => callWithNuxt(nuxt, getRequestURL) -export const joinPathToApiURLWN = (nuxt: NuxtApp, path: string) => callWithNuxt(nuxt, joinPathToApiURL, [path]) - -export const makeCWN = (func: (...args: any) => unknown) => (nuxt: NuxtApp) => callWithNuxt(nuxt, func) diff --git a/src/runtime/utils/extractFromRuntimeConfig.ts b/src/runtime/utils/extractFromRuntimeConfig.ts new file mode 100644 index 00000000..87daf4a1 --- /dev/null +++ b/src/runtime/utils/extractFromRuntimeConfig.ts @@ -0,0 +1,16 @@ +import { camelCase } from 'scule' +import type { useRuntimeConfig } from '#imports' + +type RuntimeConfig = ReturnType + +export function extractFromRuntimeConfig(config: RuntimeConfig, envVariableName: string): string | undefined { + let normalized = envVariableName.startsWith('NUXT_') + ? envVariableName.slice(5) + : envVariableName + normalized = camelCase(normalized, { normalize: true }) + + const extracted = config[normalized] + return typeof extracted === 'string' + ? extracted + : undefined +} diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index aa0e2488..cd92644f 100644 --- a/src/runtime/utils/fetch.ts +++ b/src/runtime/utils/fetch.ts @@ -1,21 +1,38 @@ -import { joinPathToApiURL } from './url' -import { callWithNuxt } from '#app/nuxt' +import { resolveApiUrlPath } from './url' +import { callWithNuxt, useRuntimeConfig } from '#app' import type { useNuxtApp } from '#imports' +const ERROR_PREFIX = '[@sidebase/nuxt-auth]' + export async function _fetch(nuxt: ReturnType, path: string, fetchOptions?: Parameters[1]): Promise { + const runtimeConfig = await callWithNuxt(nuxt, useRuntimeConfig) + const joinedPath = resolveApiUrlPath(path, runtimeConfig) + + // Prevent callback recursion when doing internal routing + if (runtimeConfig.public.auth.disableInternalRouting === false) { + const currentPath = nuxt.ssrContext?.event?.path + if (currentPath?.startsWith(joinedPath)) { + console.error(`${ERROR_PREFIX} Recursion detected at ${joinedPath}. Have you set the correct \`auth.baseURL\`?`) + throw new FetchConfigurationError('Server configuration error') + } + } + try { - const joinedPath = await callWithNuxt(nuxt, () => joinPathToApiURL(path)) return $fetch(joinedPath, fetchOptions) } catch (error) { - // TODO: Adapt this error to be more generic - console.error( - 'Error in `nuxt-auth`-app-side data fetching: Have you added the authentication handler server-endpoint `[...].ts`? Have you added the authentication handler in a non-default location (default is `~/server/api/auth/[...].ts`) and not updated the module-setting `auth.basePath`? Error is:' - ) + let errorMessage = `${ERROR_PREFIX} Error while requesting ${joinedPath}.` + if (runtimeConfig.public.auth.provider.type === 'authjs') { + errorMessage += ' Have you added the authentication handler server-endpoint `[...].ts`? Have you added the authentication handler in a non-default location (default is `~/server/api/auth/[...].ts`) and not updated the module-setting `auth.basePath`?' + } + errorMessage += ' Error is:' + console.error(errorMessage) console.error(error) - throw new Error( - 'Runtime error, checkout the console logs to debug, open an issue at https://github.com/sidebase/nuxt-auth/issues/new/choose if you continue to have this problem' + throw new FetchConfigurationError( + 'Runtime error, check the console logs to debug, open an issue at https://github.com/sidebase/nuxt-auth/issues/new/choose if you continue to have this problem' ) } } + +export class FetchConfigurationError extends Error {} diff --git a/src/runtime/utils/url.ts b/src/runtime/utils/url.ts index 31219f65..a98517a6 100644 --- a/src/runtime/utils/url.ts +++ b/src/runtime/utils/url.ts @@ -1,77 +1,79 @@ -import { joinURL } from 'ufo' -import getURL from 'requrl' -import { sanitizeStatusCode } from 'h3' -import type { ModuleOptionsNormalized } from '../types' -import { abortNavigation, useAuthState, useNuxtApp, useRequestEvent } from '#imports' +import { joinURL, parseURL, withLeadingSlash } from 'ufo' -export const getRequestURL = (includePath = true) => getURL(useRequestEvent()?.node.req, includePath) -export function joinPathToApiURL(path: string) { - const authStateInternal = useAuthState()._internal +// Slimmed down type to allow easy unit testing +interface RuntimeConfig { + public: { + auth: { + baseURL: string + disableInternalRouting: boolean + originEnvKey: string + } + } +} - // For internal calls, use a different base - // https://github.com/sidebase/nuxt-auth/issues/742 - const base = path.startsWith('/') - ? authStateInternal.pathname - : authStateInternal.baseURL +/** https://auth.sidebase.io/guide/application-side/configuration#baseurl */ +export function resolveApiUrlPath( + endpointPath: string, + runtimeConfig: RuntimeConfig +): string { + // Fully-specified endpoint path - do not join with `baseURL` + if (endpointPath.startsWith('http://') || endpointPath.startsWith('https://')) { + return endpointPath + } - return joinURL(base, path) + const baseURL = resolveApiBaseURL(runtimeConfig) + return joinURL(baseURL, endpointPath) } -/** - * Function to correctly navigate to auth-routes, necessary as the auth-routes are not part of the nuxt-app itself, so unknown to nuxt / vue-router. - * - * More specifically, we need this function to correctly handle the following cases: - * 1. On the client-side, returning `navigateTo(signInUrl)` leads to a `404` error as the next-auth-signin-page was not registered with the vue-router that is used for routing under the hood. For this reason we need to - * manually set `window.location.href` on the client **and then fake return a Promise that does not immediately resolve to block navigation (although it will not actually be fully awaited, but just be awaited long enough for the naviation to complete)**. - * 2. Additionally on the server-side, we cannot use `navigateTo(signInUrl)` as this uses `vue-router` internally which does not know the "external" sign-in page of next-auth and thus will log a warning which we want to avoid. - * - * Adapted from: https://github.com/nuxt/nuxt/blob/d188542a35bb541c7ed2e4502c687c2132979882/packages/nuxt/src/app/composables/router.ts#L161-L188 - * - * @param href HREF / URL to navigate to - */ -export function navigateToAuthPages(href: string) { - const nuxtApp = useNuxtApp() +export function resolveApiBaseURL(runtimeConfig: RuntimeConfig, returnOnlyPathname?: boolean): string { + const authRuntimeConfig = runtimeConfig.public.auth + + // If the user has not specified `returnOnlyPathname`, infer it automatically. + // When internal routing is enabled, drop everything except path. + if (returnOnlyPathname === undefined) { + returnOnlyPathname = !runtimeConfig.public.auth.disableInternalRouting + } + + // Default to static runtime config (still overridable using `NUXT_PUBLIC_AUTH_BASE_URL`) + let baseURL = authRuntimeConfig.baseURL - if (import.meta.server) { - if (nuxtApp.ssrContext) { - // TODO: consider deprecating in favour of `app:rendered` and removing - return nuxtApp.callHook('app:redirected').then(() => { - const encodedLoc = href.replace(/"/g, '%22') - const encodedHeader = new URL(href).toString() - nuxtApp.ssrContext!._renderResponse = { - statusCode: sanitizeStatusCode(302, 302), - body: ``, - headers: { location: encodedHeader }, - } - abortNavigation() - }) + // Note: the `server` condition is here because Nuxt explicitly filters out all the env variables for the Client build, + // thus the check can be safely dropped. Instead of it, the `runtime/plugin` would set the `baseURL` on the runtime config. + if (import.meta.server !== false && authRuntimeConfig.originEnvKey) { + // Override base URL using environment variable specified in `originEnvKey` if any. + // By default, would use `AUTH_ORIGIN`, can be changed by user + const envBaseURL = process.env[authRuntimeConfig.originEnvKey] + if (envBaseURL) { + baseURL = envBaseURL } } - window.location.href = href - // If href contains a hash, the browser does not reload the page. We reload manually. - if (href.includes('#')) { - window.location.reload() + if (returnOnlyPathname) { + baseURL = withLeadingSlash(parseURL(baseURL).pathname) } - // TODO: Sadly, we cannot directly import types from `vue-router` as it leads to build failures. Typing the router about should help us to avoid manually typing `route` below - const router = nuxtApp.$router as { push: (href: string) => void } + return baseURL +} - // Wait for the `window.location.href` navigation from above to complete to avoid showing content. If that doesn't work fast enough, delegate navigation back to the `vue-router` (risking a vue-router 404 warning in the console, but still avoiding content-flashes of the protected target page) - const waitForNavigationWithFallbackToRouter = new Promise(resolve => setTimeout(resolve, 60 * 1000)) - .then(() => router.push(href)) - return waitForNavigationWithFallbackToRouter as Promise +/** Slimmed down auth runtime config for `determineCallbackUrl` */ +interface AuthRuntimeConfigForCallbackUrl { + globalAppMiddleware: { + addDefaultCallbackUrl?: string | boolean + } | boolean } /** - * Determins the desired callback url based on the users desires. Either: + * 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: ModuleOptionsNormalized, getOriginalTargetPath: () => T): T | string | undefined { +export function determineCallbackUrl>( + authConfig: AuthRuntimeConfigForCallbackUrl, + getOriginalTargetPath: () => T +): T | string | undefined { const authConfigCallbackUrl = typeof authConfig.globalAppMiddleware === 'object' ? authConfig.globalAppMiddleware.addDefaultCallbackUrl : undefined diff --git a/tests/authjs.url.spec.ts b/tests/authjs.url.spec.ts new file mode 100644 index 00000000..82bd5e5f --- /dev/null +++ b/tests/authjs.url.spec.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveApiBaseURL, resolveApiUrlPath } from '../src/runtime/utils/url' + +/* + * This spec file covers usecases of the `authjs` provider. + * The main difference from `local.url.spec` is the `disableInternalRouting` flag being set to + * `false` in order to prioritize internal routing to the external one. + */ + +describe('endpoint path construction', () => { + describe('relative baseURL', () => { + it('default value', () => { + expect(testResolve('/api/auth')).toBe('/api/auth/signin') + }) + + it('default value with relative endpoint path', () => { + expect(testResolve('/api/auth', 'signin')).toBe('/api/auth/signin') + }) + + it('default value and long endpoint path', () => { + expect(testResolve('/api/auth', '/long/signin/path')).toBe('/api/auth/long/signin/path') + }) + + it('default value and long relative endpoint path', () => { + expect(testResolve('/api/auth', 'long/signin/path')).toBe('/api/auth/long/signin/path') + }) + + it('slash', () => { + expect(testResolve('/')).toBe('/signin') + }) + + it('slash with relative endpoint path', () => { + expect(testResolve('/', 'signin')).toBe('/signin') + }) + + it('empty', () => { + expect(testResolve('')).toBe('/signin') + }) + + it('empty with relative endpoint path', () => { + expect(testResolve('', 'signin')).toBe('/signin') + }) + }) + + // http://locahost:8080 + describe('localhost baseURL', () => { + it('only origin', () => { + expect(testResolve('http://localhost:8080')).toBe('/signin') + }) + + it('only origin with relative endpoint path', () => { + expect(testResolve('http://localhost:8080', 'signin')).toBe('/signin') + }) + + it('path', () => { + expect(testResolve('http://localhost:8080/auth')).toBe('/auth/signin') + }) + + it('path with relative endpoint path', () => { + expect(testResolve('http://localhost:8080/auth', 'signin')).toBe('/auth/signin') + }) + + it('path and slash', () => { + expect(testResolve('http://localhost:8080/auth/')).toBe('/auth/signin') + }) + + it('path and slash with relative endpoint path', () => { + expect(testResolve('http://localhost:8080/auth/', 'signin')).toBe('/auth/signin') + }) + + it('slash', () => { + expect(testResolve('http://localhost:8080/')).toBe('/signin') + }) + + it('slash with relative endpoint path', () => { + expect(testResolve('http://localhost:8080/', 'signin')).toBe('/signin') + }) + }) + + // https://example.com + describe('external baseURL', () => { + it('only origin', () => { + expect(testResolve('https://example.com')).toBe('/signin') + }) + + it('only origin with relative endpoint path', () => { + expect(testResolve('https://example.com', 'signin')).toBe('/signin') + }) + + it('path', () => { + expect(testResolve('https://example.com/auth')).toBe('/auth/signin') + }) + + it('path with relative endpoint path', () => { + expect(testResolve('https://example.com/auth', 'signin')).toBe('/auth/signin') + }) + + it('path and slash', () => { + expect(testResolve('https://example.com/auth/')).toBe('/auth/signin') + }) + + it('path and slash with relative endpoint path', () => { + expect(testResolve('https://example.com/auth/', 'signin')).toBe('/auth/signin') + }) + + it('slash', () => { + expect(testResolve('https://example.com/')).toBe('/signin') + }) + + it('slash with relative endpoint path', () => { + expect(testResolve('https://example.com/', 'signin')).toBe('/signin') + }) + }) + + // External endpoint paths should take priority over everything else + describe('external endpoint path', () => { + it ('http and https', () => { + expect(testResolve('/api/auth', 'http://example.com/signin')).toBe('http://example.com/signin') + expect(testResolve('/api/auth', 'https://example.com/signin')).toBe('https://example.com/signin') + }) + + it('disregards any values', () => { + const target = 'https://example.com/signin' + + expect(testResolve('', target)).toBe(target) + expect(testResolve('.', target)).toBe(target) + expect(testResolve('*', target)).toBe(target) + expect(testResolve('/', target)).toBe(target) + expect(testResolve('/api/auth', target)).toBe(target) + expect(testResolve('/api/auth/', target)).toBe(target) + expect(testResolve('http://localhost:8080', target)).toBe(target) + expect(testResolve('http://localhost:8080/', target)).toBe(target) + expect(testResolve('http://localhost:8080/auth', target)).toBe(target) + expect(testResolve('http://localhost:8080/auth/', target)).toBe(target) + expect(testResolve('https://example.com', target)).toBe(target) + expect(testResolve('https://example.com/', target)).toBe(target) + expect(testResolve('https://example.com/auth', target)).toBe(target) + expect(testResolve('https://example.com/auth/', target)).toBe(target) + }) + + it('does not consider malformed', () => { + expect(testResolve('/api/auth', 'example.com')).toBe('/api/auth/example.com') + expect(testResolve('/api/auth', 'example.com/signin')).toBe('/api/auth/example.com/signin') + }) + }) + + // Environment variables should take priority over `baseURL` + describe('env variables', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('can override default', () => { + vi.stubEnv('AUTH_ORIGIN', '/other') + expect(testResolve('/api/auth')).toBe('/other/signin') + }) + + it('can override default with fully-specified URL', () => { + vi.stubEnv('AUTH_ORIGIN', 'https://example.com/auth') + expect(testResolve('/api/auth')).toBe('/auth/signin') + }) + + it('can override using different name', () => { + vi.stubEnv('OTHER_ENV', '/other') + expect(testResolve('/api/auth', undefined, 'OTHER_ENV')).toBe('/other/signin') + }) + + it('does not use AUTH_ORIGIN when other env key is given', () => { + vi.stubEnv('AUTH_ORIGIN', '/other') + expect(testResolve('/api/auth', undefined, 'OTHER_ENV')).toBe('/api/auth/signin') + }) + + it('can override using NUXT_PUBLIC_AUTH_BASE_URL', () => { + // Unfortunately, it is not really possible to unit test the way Nuxt sets values + // on runtime config with the simple testing setup here. + // We trust Nuxt to correctly set `runtimeConfig`: https://nuxt.com/docs/guide/going-further/runtime-config#environment-variables + vi.stubEnv('NUXT_PUBLIC_AUTH_BASE_URL', '/other') + expect(testResolve(process.env.NUXT_PUBLIC_AUTH_BASE_URL as string)).toBe('/other/signin') + }) + + it('works with double assignment', () => { + // This test case is made specifically to check how `resolveApiUrlPath` would behave + // when a default `baseURL` value is being overwritten by `runtime/plugin` with a value provided by `resolveApiBaseURL`. + + // 1. `baseURL` is set to a user-provided value `https://default.example.com/api/auth`; + const initialBaseURL = 'https://example.com/api/auth' + + // 2. User also provides `originEnvKey` and sets the env to a different value `https://changed.example.com/auth/v2`; + const newBaseURL = 'https://changed.example.com/auth/v2' + const expectedNewBaseURL = '/auth/v2' + const envName = 'AUTH_ORIGIN' + vi.stubEnv(envName, newBaseURL) + + const runtimeConfig = mockRuntimeConfig(initialBaseURL, envName) + + // 3. `runtime/plugin` tries to resolve the base and gets `https://changed.example.com/auth/v2` as a result; + const resolvedNewBaseURL = resolveApiBaseURL(runtimeConfig) + expect(resolvedNewBaseURL).toBe(expectedNewBaseURL) + + // Unstub the env to emulate the client and verify that the call produces a different result + vi.unstubAllEnvs() + expect(resolveApiBaseURL(runtimeConfig)).not.toBe(expectedNewBaseURL) + + // 4. `runtime/plugin` overwrites the `baseURL`; + runtimeConfig.public.auth.baseURL = resolvedNewBaseURL + + // 5. Another code calls `resolveApiUrlPath` / `resolveApiBaseURL` and should get the changed value exactly. + const resolvedBaseURL = resolveApiBaseURL(runtimeConfig) + expect(resolvedBaseURL).toBe(expectedNewBaseURL) + const resolvedApiUrlPath = resolveApiUrlPath('/', runtimeConfig) + expect(resolvedApiUrlPath).toBe(expectedNewBaseURL) + }) + }) +}) + +function testResolve(desiredBaseURL: string, endpointPath = '/signin', envVariableName = 'AUTH_ORIGIN'): string { + const runtimeConfig = mockRuntimeConfig(desiredBaseURL, envVariableName) + return resolveApiUrlPath(endpointPath, runtimeConfig) +} + +function mockRuntimeConfig(desiredBaseURL: string, envVariableName: string) { + return { + public: { + auth: { + baseURL: desiredBaseURL, + disableInternalRouting: false, + originEnvKey: envVariableName + } + } + } +} diff --git a/tests/local.url.spec.ts b/tests/local.url.spec.ts new file mode 100644 index 00000000..a91d0a68 --- /dev/null +++ b/tests/local.url.spec.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveApiBaseURL, resolveApiUrlPath } from '../src/runtime/utils/url' + +describe('endpoint path construction', () => { + describe('relative baseURL', () => { + it('default value', () => { + expect(testResolve('/api/auth')).toBe('/api/auth/signin') + }) + + it('default value with relative endpoint path', () => { + expect(testResolve('/api/auth', 'signin')).toBe('/api/auth/signin') + }) + + it('default value and long endpoint path', () => { + expect(testResolve('/api/auth', '/long/signin/path')).toBe('/api/auth/long/signin/path') + }) + + it('default value and long relative endpoint path', () => { + expect(testResolve('/api/auth', 'long/signin/path')).toBe('/api/auth/long/signin/path') + }) + + it('slash', () => { + expect(testResolve('/')).toBe('/signin') + }) + + it('slash with relative endpoint path', () => { + expect(testResolve('/', 'signin')).toBe('/signin') + }) + + it('empty', () => { + expect(testResolve('')).toBe('/signin') + }) + + it('empty with relative endpoint path', () => { + expect(testResolve('', 'signin')).toBe('signin') + }) + }) + + // http://locahost:8080 + describe('localhost baseURL', () => { + it('only origin', () => { + expect(testResolve('http://localhost:8080')).toBe('http://localhost:8080/signin') + }) + + it('only origin with relative endpoint path', () => { + expect(testResolve('http://localhost:8080', 'signin')).toBe('http://localhost:8080/signin') + }) + + it('path', () => { + expect(testResolve('http://localhost:8080/auth')).toBe('http://localhost:8080/auth/signin') + }) + + it('path with relative endpoint path', () => { + expect(testResolve('http://localhost:8080/auth', 'signin')).toBe('http://localhost:8080/auth/signin') + }) + + it('path and slash', () => { + expect(testResolve('http://localhost:8080/auth/')).toBe('http://localhost:8080/auth/signin') + }) + + it('path and slash with relative endpoint path', () => { + expect(testResolve('http://localhost:8080/auth/', 'signin')).toBe('http://localhost:8080/auth/signin') + }) + + it('slash', () => { + expect(testResolve('http://localhost:8080/')).toBe('http://localhost:8080/signin') + }) + + it('slash with relative endpoint path', () => { + expect(testResolve('http://localhost:8080/', 'signin')).toBe('http://localhost:8080/signin') + }) + }) + + // https://example.com + describe('external baseURL', () => { + it('only origin', () => { + expect(testResolve('https://example.com')).toBe('https://example.com/signin') + }) + + it('only origin with relative endpoint path', () => { + expect(testResolve('https://example.com', 'signin')).toBe('https://example.com/signin') + }) + + it('path', () => { + expect(testResolve('https://example.com/auth')).toBe('https://example.com/auth/signin') + }) + + it('path with relative endpoint path', () => { + expect(testResolve('https://example.com/auth', 'signin')).toBe('https://example.com/auth/signin') + }) + + it('path and slash', () => { + expect(testResolve('https://example.com/auth/')).toBe('https://example.com/auth/signin') + }) + + it('path and slash with relative endpoint path', () => { + expect(testResolve('https://example.com/auth/', 'signin')).toBe('https://example.com/auth/signin') + }) + + it('slash', () => { + expect(testResolve('https://example.com/')).toBe('https://example.com/signin') + }) + + it('slash with relative endpoint path', () => { + expect(testResolve('https://example.com/', 'signin')).toBe('https://example.com/signin') + }) + }) + + // External endpoint paths should take priority over everything else + describe('external endpoint path', () => { + it ('http and https', () => { + expect(testResolve('/api/auth', 'http://example.com/signin')).toBe('http://example.com/signin') + expect(testResolve('/api/auth', 'https://example.com/signin')).toBe('https://example.com/signin') + }) + + it('disregards any values', () => { + const target = 'https://example.com/signin' + + expect(testResolve('', target)).toBe(target) + expect(testResolve('.', target)).toBe(target) + expect(testResolve('*', target)).toBe(target) + expect(testResolve('/', target)).toBe(target) + expect(testResolve('/api/auth', target)).toBe(target) + expect(testResolve('/api/auth/', target)).toBe(target) + expect(testResolve('http://localhost:8080', target)).toBe(target) + expect(testResolve('http://localhost:8080/', target)).toBe(target) + expect(testResolve('http://localhost:8080/auth', target)).toBe(target) + expect(testResolve('http://localhost:8080/auth/', target)).toBe(target) + expect(testResolve('https://example.com', target)).toBe(target) + expect(testResolve('https://example.com/', target)).toBe(target) + expect(testResolve('https://example.com/auth', target)).toBe(target) + expect(testResolve('https://example.com/auth/', target)).toBe(target) + }) + + it('does not consider malformed', () => { + expect(testResolve('/api/auth', 'example.com')).toBe('/api/auth/example.com') + expect(testResolve('/api/auth', 'example.com/signin')).toBe('/api/auth/example.com/signin') + }) + }) + + // Environment variables should take priority over `baseURL` + describe('env variables', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('can override default', () => { + vi.stubEnv('AUTH_ORIGIN', '/other') + expect(testResolve('/api/auth')).toBe('/other/signin') + }) + + it('can override default with fully-specified URL', () => { + vi.stubEnv('AUTH_ORIGIN', 'https://example.com/auth') + expect(testResolve('/api/auth')).toBe('https://example.com/auth/signin') + }) + + it('can override using different name', () => { + vi.stubEnv('OTHER_ENV', '/other') + expect(testResolve('/api/auth', undefined, 'OTHER_ENV')).toBe('/other/signin') + }) + + it('does not use AUTH_ORIGIN when other env key is given', () => { + vi.stubEnv('AUTH_ORIGIN', '/other') + expect(testResolve('/api/auth', undefined, 'OTHER_ENV')).toBe('/api/auth/signin') + }) + + it('can override using NUXT_PUBLIC_AUTH_BASE_URL', () => { + // Unfortunately, it is not really possible to unit test the way Nuxt sets values + // on runtime config with the simple testing setup here. + // We trust Nuxt to correctly set `runtimeConfig`: https://nuxt.com/docs/guide/going-further/runtime-config#environment-variables + vi.stubEnv('NUXT_PUBLIC_AUTH_BASE_URL', '/other') + expect(testResolve(process.env.NUXT_PUBLIC_AUTH_BASE_URL as string)).toBe('/other/signin') + }) + + it('works with double assignment', () => { + // This test case is made specifically to check how `resolveApiUrlPath` would behave + // when a default `baseURL` value is being overwritten by `runtime/plugin` with a value provided by `resolveApiBaseURL`. + + // 1. `baseURL` is set to a user-provided value `https://default.example.com/api/auth`; + const initialBaseURL = 'https://example.com/api/auth' + + // 2. User also provides `originEnvKey` and sets the env to a different value `https://changed.example.com/auth`; + const expectedNewBaseURL = 'https://changed.example.com/auth' + const envName = 'AUTH_ORIGIN' + vi.stubEnv(envName, expectedNewBaseURL) + + const runtimeConfig = mockRuntimeConfig(initialBaseURL, envName) + + // 3. `runtime/plugin` tries to resolve the base and gets `https://changed.example.com/auth` as a result; + const resolvedNewBaseURL = resolveApiBaseURL(runtimeConfig) + expect(resolvedNewBaseURL).toBe(expectedNewBaseURL) + + // Unstub the env to emulate the client and verify that the call produces a different result + vi.unstubAllEnvs() + expect(resolveApiBaseURL(runtimeConfig)).not.toBe(expectedNewBaseURL) + + // 4. `runtime/plugin` overwrites the `baseURL`; + runtimeConfig.public.auth.baseURL = resolvedNewBaseURL + + // 5. Another code calls `resolveApiUrlPath` / `resolveApiBaseURL` and should get the changed value exactly. + const resolvedBaseURL = resolveApiBaseURL(runtimeConfig) + expect(resolvedBaseURL).toBe(expectedNewBaseURL) + const resolvedApiUrlPath = resolveApiUrlPath('/', runtimeConfig) + expect(resolvedApiUrlPath).toBe(expectedNewBaseURL) + }) + }) +}) + +function testResolve(desiredBaseURL: string, endpointPath = '/signin', envVariableName = 'AUTH_ORIGIN'): string { + const runtimeConfig = mockRuntimeConfig(desiredBaseURL, envVariableName) + return resolveApiUrlPath(endpointPath, runtimeConfig) +} + +function mockRuntimeConfig(desiredBaseURL: string, envVariableName: string) { + return { + public: { + auth: { + baseURL: desiredBaseURL, + disableInternalRouting: true, + originEnvKey: envVariableName + } + } + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..843ed788 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/*.spec.ts'] + } +}) From 37b0c65b3294fcbc897976a96a3774595a594a00 Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 12 Dec 2024 13:19:08 +0100 Subject: [PATCH 2/3] chore(#892): rename middleware to avoid conflicts (#957) --- docs/guide/application-side/protecting-pages.md | 2 +- src/module.ts | 4 ++-- src/runtime/middleware/{auth.ts => sidebase-auth.ts} | 0 src/runtime/plugin.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/runtime/middleware/{auth.ts => sidebase-auth.ts} (100%) diff --git a/docs/guide/application-side/protecting-pages.md b/docs/guide/application-side/protecting-pages.md index 0df29fd0..e8fe50e6 100644 --- a/docs/guide/application-side/protecting-pages.md +++ b/docs/guide/application-side/protecting-pages.md @@ -32,7 +32,7 @@ If the global middleware is disabled, you can manually add the middleware to ind ```vue diff --git a/src/module.ts b/src/module.ts index fc8320b3..b578366d 100644 --- a/src/module.ts +++ b/src/module.ts @@ -241,8 +241,8 @@ export default defineNuxtModule({ // 6. Register middleware for autocomplete in definePageMeta addRouteMiddleware({ - name: 'auth', - path: resolve('./runtime/middleware/auth') + name: 'sidebase-auth', + path: resolve('./runtime/middleware/sidebase-auth') }) // 7. Add plugin for initial load diff --git a/src/runtime/middleware/auth.ts b/src/runtime/middleware/sidebase-auth.ts similarity index 100% rename from src/runtime/middleware/auth.ts rename to src/runtime/middleware/sidebase-auth.ts diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index c05e8e35..b5d29a0f 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -1,5 +1,5 @@ import { getHeader } from 'h3' -import authMiddleware from './middleware/auth' +import authMiddleware from './middleware/sidebase-auth' import { getNitroRouteRules } from './utils/kit' import { FetchConfigurationError } from './utils/fetch' import { resolveApiBaseURL } from './utils/url' From b1e4abad275c020daf8e227c1b4c9c7d1e485465 Mon Sep 17 00:00:00 2001 From: Thomas <36050256+Thomas-Philippot@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:16:18 +0100 Subject: [PATCH 3/3] enh(#935): allow external login page (#936) * allow external link as login page * allow external login page * fix typecheck * chore: adjust the external handling --------- Co-authored-by: Thomas Co-authored-by: Zoey Co-authored-by: Marsel Shaikhin --- src/runtime/middleware/sidebase-auth.ts | 18 +++++++++++++----- src/runtime/utils/fetch.ts | 3 +-- src/runtime/utils/logger.ts | 1 + src/runtime/utils/url.ts | 11 ++++++++++- 4 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 src/runtime/utils/logger.ts diff --git a/src/runtime/middleware/sidebase-auth.ts b/src/runtime/middleware/sidebase-auth.ts index 094ed2d1..76409d69 100644 --- a/src/runtime/middleware/sidebase-auth.ts +++ b/src/runtime/middleware/sidebase-auth.ts @@ -1,5 +1,6 @@ -import { determineCallbackUrl } from '../utils/url' +import { determineCallbackUrl, isExternalUrl } from '../utils/url' import { isProduction } from '../helpers' +import { ERROR_PREFIX } from '../utils/logger' import { defineNuxtRouteMiddleware, navigateTo, useAuth, useRuntimeConfig } from '#imports' type MiddlewareMeta = boolean | { @@ -98,7 +99,14 @@ export default defineNuxtRouteMiddleware((to) => { return navigateTo(options.navigateUnauthenticatedTo) } + const loginPage = authConfig.provider.pages.login + if (typeof loginPage !== 'string') { + console.warn(`${ERROR_PREFIX} provider.pages.login is misconfigured`) + return + } + // Default callback URL was provided + const external = isExternalUrl(loginPage) if (typeof globalAppMiddleware === 'object' && globalAppMiddleware.addDefaultCallbackUrl) { let redirectUrl: string = to.fullPath if (typeof globalAppMiddleware.addDefaultCallbackUrl === 'string') { @@ -106,15 +114,15 @@ export default defineNuxtRouteMiddleware((to) => { } return navigateTo({ - path: authConfig.provider.pages.login, + path: loginPage, query: { redirect: redirectUrl } - }) + }, { external }) } // Fall back to login page - return navigateTo(authConfig.provider.pages.login) + return navigateTo(loginPage, { external }) }) interface MiddlewareOptionsNormalized { @@ -146,7 +154,7 @@ function normalizeUserOptions(userOptions: MiddlewareMeta | undefined): Middlewa if (userOptions.unauthenticatedOnly === undefined) { if (!isProduction) { console.warn( - '[@sidebase/nuxt-auth] `unauthenticatedOnly` was not provided to `definePageMeta` - defaulting to Guest Mode enabled. ' + `${ERROR_PREFIX} \`unauthenticatedOnly\` was not provided to \`definePageMeta\` - defaulting to Guest Mode enabled. ` + 'Read more at https://auth.sidebase.io/guide/application-side/protecting-pages#middleware-options' ) } diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index cd92644f..f1eb8902 100644 --- a/src/runtime/utils/fetch.ts +++ b/src/runtime/utils/fetch.ts @@ -1,9 +1,8 @@ import { resolveApiUrlPath } from './url' +import { ERROR_PREFIX } from './logger' import { callWithNuxt, useRuntimeConfig } from '#app' import type { useNuxtApp } from '#imports' -const ERROR_PREFIX = '[@sidebase/nuxt-auth]' - export async function _fetch(nuxt: ReturnType, path: string, fetchOptions?: Parameters[1]): Promise { const runtimeConfig = await callWithNuxt(nuxt, useRuntimeConfig) const joinedPath = resolveApiUrlPath(path, runtimeConfig) diff --git a/src/runtime/utils/logger.ts b/src/runtime/utils/logger.ts new file mode 100644 index 00000000..2a804260 --- /dev/null +++ b/src/runtime/utils/logger.ts @@ -0,0 +1 @@ +export const ERROR_PREFIX = '[@sidebase/nuxt-auth]' diff --git a/src/runtime/utils/url.ts b/src/runtime/utils/url.ts index a98517a6..847a3d73 100644 --- a/src/runtime/utils/url.ts +++ b/src/runtime/utils/url.ts @@ -17,7 +17,7 @@ export function resolveApiUrlPath( runtimeConfig: RuntimeConfig ): string { // Fully-specified endpoint path - do not join with `baseURL` - if (endpointPath.startsWith('http://') || endpointPath.startsWith('https://')) { + if (isExternalUrl(endpointPath)) { return endpointPath } @@ -95,3 +95,12 @@ export function determineCallbackUrl>( return getOriginalTargetPath() } } + +/** + * Naively checks if a URL is external or not by comparing against its protocol. + * + * URL being valid is not a concern for this function as it is used with developer-controlled inputs. + */ +export function isExternalUrl(url: string): boolean { + return url.startsWith('http://') || url.startsWith('https://') +}