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/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/docs/guide/local/quick-start.md b/docs/guide/local/quick-start.md
index 22d5295a..cbac5f16 100644
--- a/docs/guide/local/quick-start.md
+++ b/docs/guide/local/quick-start.md
@@ -224,6 +224,7 @@ export default defineNuxtConfig({
refreshOnlyToken: true,
token: {
signInResponseRefreshTokenPointer: '/refresh-token',
+ refreshResponseTokenPointer: '',
refreshRequestTokenPointer: '/refresh-token',
cookieName: 'auth.token',
maxAgeInSeconds: 1800,
@@ -291,6 +292,19 @@ E.g., setting this to `/token/refreshToken` and returning an object like `{ toke
This follows the JSON Pointer standard, see its RFC6901 here: https://www.rfc-editor.org/rfc/rfc6901
+#### `refreshResponseTokenPointer`
+
+- **Type:** `string`
+- **Default:** `''`
+
+How to extract the authentication-token from the refresh response.
+
+E.g., setting this to `/token/bearer` and returning an object like `{ token: { bearer: 'THE_AUTH_TOKEN' }, timestamp: '2023' }` from the `refresh` endpoint will result in `nuxt-auth` extracting and storing `THE_AUTH_TOKEN`.
+
+If not set, `token.signInResponseTokenPointer` will be used instead.
+
+This follows the JSON Pointer standard, see its RFC6901 here: https://www.rfc-editor.org/rfc/rfc6901
+
#### `refreshRequestTokenPointer`
- **Type:** `string`
diff --git a/package.json b/package.json
index 7328de49..fab92e8e 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",
@@ -62,6 +63,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/playground-local/nuxt.config.ts b/playground-local/nuxt.config.ts
index 4f6e5c5e..75392d66 100644
--- a/playground-local/nuxt.config.ts
+++ b/playground-local/nuxt.config.ts
@@ -27,6 +27,7 @@ export default defineNuxtConfig({
endpoint: { path: '/refresh', method: 'post' },
token: {
signInResponseRefreshTokenPointer: '/token/refreshToken',
+ refreshResponseTokenPointer: '',
refreshRequestTokenPointer: '/refreshToken'
},
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ce6ca395..147dc82e 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)
@@ -12663,7 +12666,6 @@ snapshots:
- sugarss
- supports-color
- terser
- optional: true
vite-node@1.6.0(@types/node@18.19.45)(terser@5.30.3):
dependencies:
@@ -12910,7 +12912,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:
@@ -13064,7 +13065,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 1ec9f43a..b578366d 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: {
@@ -77,6 +78,7 @@ const defaultsByBackend: {
refreshOnlyToken: true,
token: {
signInResponseRefreshTokenPointer: '/refreshToken',
+ refreshResponseTokenPointer: '',
refreshRequestTokenPointer: '/refreshToken',
cookieName: 'auth.refresh-token',
maxAgeInSeconds: 60 * 60 * 24 * 7, // 7 days
@@ -107,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) {
@@ -136,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: {} }
@@ -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/composables/authjs/useAuth.ts b/src/runtime/composables/authjs/useAuth.ts
index 23e40ad6..edbd7c9c 100644
--- a/src/runtime/composables/authjs/useAuth.ts
+++ b/src/runtime/composables/authjs/useAuth.ts
@@ -2,13 +2,14 @@ import type { AppProvider, BuiltInProviderType } from '@auth/core/providers/inde
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}`
@@ -143,7 +145,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 {
@@ -166,7 +167,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)
@@ -225,7 +226,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 1b311dfc..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
@@ -199,11 +199,12 @@ async function refresh(getSessionOptions?: GetSessionOptions) {
})
// Extract the new token from the refresh response
- const extractedToken = jsonPointerGet(response, config.token.signInResponseTokenPointer)
+ const tokenPointer = config.refresh.token.refreshResponseTokenPointer || config.token.signInResponseTokenPointer
+ const extractedToken = jsonPointerGet(response, tokenPointer)
if (typeof extractedToken !== 'string') {
console.error(
`Auth: string token expected, received instead: ${JSON.stringify(extractedToken)}. `
- + `Tried to find token at ${config.token.signInResponseTokenPointer} in ${JSON.stringify(response)}`
+ + `Tried to find token at ${tokenPointer} in ${JSON.stringify(response)}`
)
return
}
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/sidebase-auth.ts
similarity index 89%
rename from src/runtime/middleware/auth.ts
rename to src/runtime/middleware/sidebase-auth.ts
index b1cb4992..76409d69 100644
--- a/src/runtime/middleware/auth.ts
+++ b/src/runtime/middleware/sidebase-auth.ts
@@ -1,6 +1,6 @@
-import type { navigateToAuthPages } from '../utils/url'
-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 | {
@@ -74,7 +74,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 +91,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
@@ -99,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') {
@@ -107,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 {
@@ -147,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/plugin.ts b/src/runtime/plugin.ts
index 75952fad..b5d29a0f 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 authMiddleware from './middleware/sidebase-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/plugins/refresh-token.server.ts b/src/runtime/plugins/refresh-token.server.ts
index 1d05065d..aa45f77d 100644
--- a/src/runtime/plugins/refresh-token.server.ts
+++ b/src/runtime/plugins/refresh-token.server.ts
@@ -32,16 +32,16 @@ export default defineNuxtPlugin({
headers
})
+ const tokenPointer = provider.refresh.token.refreshResponseTokenPointer || provider.token.signInResponseTokenPointer
const extractedToken = jsonPointerGet(
response,
- provider.token.signInResponseTokenPointer
+ tokenPointer
)
if (typeof extractedToken !== 'string') {
console.error(
`Auth: string token expected, received instead: ${JSON.stringify(
extractedToken
- )}. Tried to find token at ${
- provider.token.signInResponseTokenPointer
+ )}. Tried to find token at ${tokenPointer
} in ${JSON.stringify(response)}`
)
return
@@ -57,8 +57,7 @@ export default defineNuxtPlugin({
console.error(
`Auth: string token expected, received instead: ${JSON.stringify(
extractedRefreshToken
- )}. Tried to find token at ${
- provider.refresh.token.signInResponseRefreshTokenPointer
+ )}. Tried to find token at ${provider.refresh.token.signInResponseRefreshTokenPointer
} in ${JSON.stringify(response)}`
)
return
diff --git a/src/runtime/server/services/authjs/nuxtAuthHandler.ts b/src/runtime/server/services/authjs/nuxtAuthHandler.ts
index de3cb797..973e062b 100644
--- a/src/runtime/server/services/authjs/nuxtAuthHandler.ts
+++ b/src/runtime/server/services/authjs/nuxtAuthHandler.ts
@@ -12,7 +12,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: Request) => Promise) | undefined
@@ -74,15 +74,15 @@ export function NuxtAuthHandler(nuxtAuthOptions?: AuthConfig) {
/** 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 (!preparedAuthjsHandlerRaw) {
// 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 17152cec..9b037855 100644
--- a/src/runtime/types.ts
+++ b/src/runtime/types.ts
@@ -255,6 +255,21 @@ export interface ProviderLocal {
* @example / Access the root of the sign-in response object, useful when your endpoint returns a plain, non-object string as the token
*/
signInResponseRefreshTokenPointer?: string
+ /**
+ * How to extract the authentication-token from the refresh response.
+ *
+ *
+ * E.g., setting this to `/token/bearer` and returning an object like `{ token: { bearer: 'THE_AUTH_TOKEN' }, timestamp: '2023' }` from the `refresh` endpoint will
+ * result in `nuxt-auth` extracting and storing `THE_AUTH_TOKEN`.
+ *
+ * If not set, `token.signInResponseTokenPointer` will be used instead.
+ *
+ * This follows the JSON Pointer standard, see it's RFC6901 here: https://www.rfc-editor.org/rfc/rfc6901
+ *
+ * @default ''
+ * @example / Access the root of the refresh response object, useful when your endpoint returns a plain, non-object string as the token
+ */
+ refreshResponseTokenPointer?: string
/**
* How to do a fetch for the refresh token.
*
@@ -409,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.
*
@@ -522,10 +552,6 @@ export interface CommonUseAuthStateReturn {
loading: Ref
lastRefreshedAt: Ref
status: ComputedRef
- _internal: {
- baseURL: string
- pathname: string
- }
}
// Common `useAuth` method-types
@@ -589,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..f1eb8902 100644
--- a/src/runtime/utils/fetch.ts
+++ b/src/runtime/utils/fetch.ts
@@ -1,21 +1,37 @@
-import { joinPathToApiURL } from './url'
-import { callWithNuxt } from '#app/nuxt'
+import { resolveApiUrlPath } from './url'
+import { ERROR_PREFIX } from './logger'
+import { callWithNuxt, useRuntimeConfig } from '#app'
import type { useNuxtApp } from '#imports'
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/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 31219f65..847a3d73 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 (isExternalUrl(endpointPath)) {
+ 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 (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()
- })
+ // 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
+
+ // 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
@@ -93,3 +95,12 @@ export function determineCallbackUrl>(authCon
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://')
+}
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']
+ }
+})