diff --git a/playground-authjs/pages/custom-signin.vue b/playground-authjs/pages/custom-signin.vue index 37283f70..a2d27285 100644 --- a/playground-authjs/pages/custom-signin.vue +++ b/playground-authjs/pages/custom-signin.vue @@ -19,6 +19,8 @@ async function mySignInHandler({ username, password, callbackUrl }: { username: } else { // No error, continue with the sign in, e.g., by following the returned redirect: + // Note that in failure cases (when `error` is not null) redirect is followed automatically, + // i.e. `redirect` param only applies to successful sign-in. return navigateTo(url, { external: true }) } } diff --git a/playground-local/pages/protected/locally.vue b/playground-local/pages/protected/locally.vue index c70d0356..dd3dbacf 100644 --- a/playground-local/pages/protected/locally.vue +++ b/playground-local/pages/protected/locally.vue @@ -3,7 +3,7 @@ import { definePageMeta } from '#imports' // Note: This is only for testing, it does not make sense to do this with `globalAppMiddleware` turned on definePageMeta({ - middleware: 'auth' + middleware: 'sidebase-auth' }) diff --git a/playground-local/pages/register.vue b/playground-local/pages/register.vue index 7d058345..df8c3cc2 100644 --- a/playground-local/pages/register.vue +++ b/playground-local/pages/register.vue @@ -14,7 +14,7 @@ async function register() { response.value = await signUp({ username: username.value, password: password.value - }, undefined, { preventLoginFlow: true }) + }, { preventLoginFlow: true }) } catch (error) { if (error instanceof FetchError) { diff --git a/src/runtime/composables/authjs/useAuth.ts b/src/runtime/composables/authjs/useAuth.ts index 16ae5657..f81d3673 100644 --- a/src/runtime/composables/authjs/useAuth.ts +++ b/src/runtime/composables/authjs/useAuth.ts @@ -6,7 +6,7 @@ import { appendHeader } from 'h3' import { resolveApiUrlPath } from '../../utils/url' import { _fetch } from '../../utils/fetch' import { isNonEmptyObject } from '../../utils/checkSessionResult' -import type { CommonUseAuthReturn, GetSessionOptions, SignInFunc, SignOutFunc } from '../../types' +import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions } from '../../types' import { useTypedBackendConfig } from '../../helpers' import { getRequestURLWN } from '../common/getRequestURL' import { determineCallbackUrl } from '../../utils/callbackUrl' @@ -26,268 +26,319 @@ type LiteralUnion = T | (U & Record) // TODO: Stronger typing for `provider`, see https://github.com/nextauthjs/next-auth/blob/733fd5f2345cbf7c123ba8175ea23506bcb5c453/packages/next-auth/src/react/index.tsx#L199-L203 export type SupportedProviders = LiteralUnion | undefined -/** - * Utilities to make nested async composable calls play nicely with nuxt. - * - * Calling nested async composable can lead to "nuxt instance unavailable" errors. See more details here: https://github.com/nuxt/framework/issues/5740#issuecomment-1229197529. To resolve this we can manually ensure that the nuxt-context is set. This module contains `callWithNuxt` helpers for some of the methods that are frequently called in nested `useAuth` composable calls. - */ -async function getRequestHeaders(nuxt: NuxtApp, includeCookie = true): Promise<{ cookie?: string, host?: string }> { - // `useRequestHeaders` is sync, so we narrow it to the awaited return type here - const headers = await callWithNuxt(nuxt, () => useRequestHeaders(['cookie', 'host'])) - if (includeCookie && headers.cookie) { - return headers - } - return { host: headers.host } +interface SignInResult { + error: string | null + status: number + ok: boolean + url: any } -/** - * Get the current Cross-Site Request Forgery token. - * - * You can use this to pass along for certain requests, most of the time you will not need it. - */ -async function getCsrfToken() { - const nuxt = useNuxtApp() - const headers = await getRequestHeaders(nuxt) - return _fetch<{ csrfToken: string }>(nuxt, '/csrf', { headers }).then(response => response.csrfToken) +export interface SignInFunc { + ( + provider: SupportedProviders, + signInOptions?: SecondarySignInOptions, + paramsOptions?: Record, + headersOptions?: Record + ): Promise } -function getCsrfTokenWithNuxt(nuxt: NuxtApp) { - return callWithNuxt(nuxt, getCsrfToken) + +export interface SignOutFunc { + (options?: SignOutOptions): Promise } -/** - * Trigger a sign in flow for the passed `provider`. If no provider is given the sign in page for all providers will be shown. - * - * @param provider - Provider to trigger sign in flow for. Leave empty to show page with all providers - * @param options - Sign in options, everything you pass here will be passed with the body of the sign-in request. You can use this to include provider-specific data, e.g., the username and password for the `credential` flow - * @param authorizationParams - Everything you put in here is passed along as url-parameters in the sign-in request. https://github.com/nextauthjs/next-auth/blob/733fd5f2345cbf7c123ba8175ea23506bcb5c453/packages/next-auth/src/react/types.ts#L44-L49 - */ -type SignInResult = void | { error: string | null, status: number, ok: boolean, url: any } -const signIn: SignInFunc = async (provider, options, authorizationParams) => { +export interface GetSessionFunc { + (getSessionOptions?: GetSessionOptions): Promise +} + +export interface GetCsrfTokenFunc { + (): Promise +} + +export type GetProvidersResult = Record, Omit | undefined> +export interface GetProvidersFunc { + (): Promise +} + +interface UseAuthReturn extends CommonUseAuthReturn { + getCsrfToken: GetCsrfTokenFunc + getProviders: GetProvidersFunc +} + +export function useAuth(): UseAuthReturn { const nuxt = useNuxtApp() const runtimeConfig = useRuntimeConfig() + const backendConfig = useTypedBackendConfig(runtimeConfig, 'authjs') - // 1. Lead to error page if no providers are available - const configuredProviders = await getProviders() - if (!configuredProviders) { - const errorUrl = resolveApiUrlPath('error', runtimeConfig) - return navigateToAuthPageWN(nuxt, errorUrl, true) - } + const { + data, + loading, + status, + lastRefreshedAt + } = useAuthState() - // 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 backendConfig = useTypedBackendConfig(runtimeConfig, 'authjs') - if (typeof provider === 'undefined') { - // NOTE: `provider` might be an empty string - provider = backendConfig.defaultProvider - } + /** + * Trigger a sign in flow for the passed `provider`. If no provider is given the sign in page for all providers will be shown. + * + * @param provider - Provider to trigger sign in flow for. Leave empty to show page with all providers + * @param options - Sign in options, everything you pass here will be passed with the body of the sign-in request. You can use this to include provider-specific data, e.g., the username and password for the `credential` flow + * @param authorizationParams - Everything you put in here is passed along as url-parameters in the sign-in request. https://github.com/nextauthjs/next-auth/blob/733fd5f2345cbf7c123ba8175ea23506bcb5c453/packages/next-auth/src/react/types.ts#L44-L49 + */ + async function signIn( + provider: SupportedProviders, + options?: SecondarySignInOptions, + authorizationParams?: Record + ): Promise { + // 1. Lead to error page if no providers are available + const configuredProviders = await getProviders() + if (!configuredProviders) { + const errorUrl = resolveApiUrlPath('error', runtimeConfig) + await navigateToAuthPageWN(nuxt, errorUrl, true) + + return { + // Future AuthJS compat here and in other places + // https://authjs.dev/reference/core/errors#invalidprovider + error: 'InvalidProvider', + ok: false, + status: 500, + url: errorUrl + } + } - // 3. Redirect to the general sign-in page with all providers in case either no provider or no valid provider was selected - const { redirect = true } = options ?? {} + // 2. If no `provider` was given, either use the configured `defaultProvider` or `undefined` (leading to a forward to the `/login` page with all providers) + if (typeof provider === 'undefined') { + // NOTE: `provider` might be an empty string + provider = backendConfig.defaultProvider + } - const callbackUrl = await callWithNuxt(nuxt, () => determineCallbackUrl(runtimeConfig.public.auth, options?.callbackUrl)) + // 3. Redirect to the general sign-in page with all providers in case either no provider or no valid provider was selected + const { redirect = true } = options ?? {} - const signinUrl = resolveApiUrlPath('signin', runtimeConfig) + const callbackUrl = await callWithNuxt(nuxt, () => determineCallbackUrl(runtimeConfig.public.auth, options?.callbackUrl)) - const queryParams = callbackUrl ? `?${new URLSearchParams({ callbackUrl })}` : '' - const hrefSignInAllProviderPage = `${signinUrl}${queryParams}` - if (!provider) { - return navigateToAuthPageWN(nuxt, hrefSignInAllProviderPage, true) - } + const signinUrl = resolveApiUrlPath('signin', runtimeConfig) - const selectedProvider = configuredProviders[provider] - if (!selectedProvider) { - return navigateToAuthPageWN(nuxt, hrefSignInAllProviderPage, true) - } + const queryParams = callbackUrl ? `?${new URLSearchParams({ callbackUrl })}` : '' + const hrefSignInAllProviderPage = `${signinUrl}${queryParams}` - // 4. Perform a sign-in straight away with the selected provider - const isCredentials = selectedProvider.type === 'credentials' - const isEmail = selectedProvider.type === 'email' - const isSupportingReturn = isCredentials || isEmail + const selectedProvider = provider && configuredProviders[provider] + if (!selectedProvider) { + await navigateToAuthPageWN(nuxt, hrefSignInAllProviderPage, true) - let action: 'callback' | 'signin' = 'signin' - if (isCredentials) { - action = 'callback' - } + return { + // https://authjs.dev/reference/core/errors#invalidprovider + error: 'InvalidProvider', + ok: false, + status: 400, + url: hrefSignInAllProviderPage + } + } - const csrfToken = await callWithNuxt(nuxt, getCsrfToken) + // 4. Perform a sign-in straight away with the selected provider + const isCredentials = selectedProvider.type === 'credentials' + const isEmail = selectedProvider.type === 'email' + const isSupportingReturn = isCredentials || isEmail - const headers: { 'Content-Type': string, 'cookie'?: string, 'host'?: string } = { - 'Content-Type': 'application/x-www-form-urlencoded', - ...(await getRequestHeaders(nuxt)) - } + const action: 'callback' | 'signin' = isCredentials ? 'callback' : 'signin' - // @ts-expect-error `options` is typed as any, but is a valid parameter for URLSearchParams - const body = new URLSearchParams({ - ...options, - csrfToken, - callbackUrl, - json: true - }) - - const fetchSignIn = () => _fetch<{ url: string }>(nuxt, `/${action}/${provider}`, { - method: 'post', - params: authorizationParams, - headers, - body - }).catch>((error: { data: any }) => error.data) - const data = await callWithNuxt(nuxt, fetchSignIn) - - if (redirect || !isSupportingReturn) { - const href = data.url ?? callbackUrl - return navigateToAuthPageWN(nuxt, href) - } + const csrfToken = await getCsrfTokenWithNuxt(nuxt) - // At this point the request succeeded (i.e., it went through) - const error = new URL(data.url).searchParams.get('error') - await getSessionWithNuxt(nuxt) + const headers: { 'Content-Type': string, 'cookie'?: string, 'host'?: string } = { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(await getRequestHeaders(nuxt)) + } - return { - error, - status: 200, - ok: true, - url: error ? null : data.url - } -} + // @ts-expect-error `options` is typed as any, but is a valid parameter for URLSearchParams + const body = new URLSearchParams({ + ...options, + csrfToken, + callbackUrl, + json: true + }) -/** - * Get all configured providers from the backend. You can use this method to build your own sign-in page. - */ -async function getProviders() { - const nuxt = useNuxtApp() - // Pass the `Host` header when making internal requests - const headers = await getRequestHeaders(nuxt, false) - - return _fetch, Omit | undefined>>( - nuxt, - '/providers', - { headers } - ) -} + const fetchSignIn = () => _fetch<{ url: string }>(nuxt, `/${action}/${provider}`, { + method: 'post', + params: authorizationParams, + headers, + body + }).catch>((error: { data: any }) => error.data) + const data = await callWithNuxt(nuxt, fetchSignIn) + + if (redirect || !isSupportingReturn) { + const href = data.url ?? callbackUrl + await navigateToAuthPageWN(nuxt, href) + + // We use `http://_` as a base to allow relative URLs in `callbackUrl`. We only need the `error` query param + const error = new URL(href, 'http://_').searchParams.get('error') + + return { + error, + ok: true, + status: 302, + url: href + } + } -/** - * Refresh and get the current session data. - * - * @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 { - const nuxt = useNuxtApp() + // At this point the request succeeded (i.e., it went through) + const error = new URL(data.url).searchParams.get('error') + await getSessionWithNuxt(nuxt) - const callbackUrlFallback = await getRequestURLWN(nuxt) - const { required, callbackUrl, onUnauthenticated } = defu(getSessionOptions || {}, { - required: false, - callbackUrl: undefined, - onUnauthenticated: () => signIn(undefined, { - callbackUrl: getSessionOptions?.callbackUrl || callbackUrlFallback - }) - }) + return { + error, + status: 200, + ok: true, + url: error ? null : data.url + } + } - const { data, status, loading, lastRefreshedAt } = await callWithNuxt(nuxt, useAuthState) - const onError = () => { - loading.value = false + /** + * Get all configured providers from the backend. You can use this method to build your own sign-in page. + */ + async function getProviders() { + // Pass the `Host` header when making internal requests + const headers = await getRequestHeaders(nuxt, false) + + return _fetch( + nuxt, + '/providers', + { headers } + ) } - const headers = await getRequestHeaders(nuxt) + /** + * Refresh and get the current session data. + * + * @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 { + const callbackUrlFallback = await getRequestURLWN(nuxt) + const { required, callbackUrl, onUnauthenticated } = defu(getSessionOptions || {}, { + required: false, + callbackUrl: undefined, + onUnauthenticated: () => signIn(undefined, { + callbackUrl: getSessionOptions?.callbackUrl || callbackUrlFallback + }) + }) - return _fetch(nuxt, '/session', { - onResponse: ({ response }) => { - const sessionData = response._data + function onError() { + loading.value = false + } - // Add any new cookie to the server-side event for it to be present on the app-side after - // initial load, see sidebase/nuxt-auth/issues/200 for more information. - if (import.meta.server) { - const setCookieValues = response.headers.getSetCookie ? response.headers.getSetCookie() : [response.headers.get('set-cookie')] - if (setCookieValues && nuxt.ssrContext) { - for (const value of setCookieValues) { - if (!value) { - continue + const headers = await getRequestHeaders(nuxt) + + return _fetch(nuxt, '/session', { + onResponse: ({ response }) => { + const sessionData = response._data + + // Add any new cookie to the server-side event for it to be present on the app-side after + // initial load, see sidebase/nuxt-auth/issues/200 for more information. + if (import.meta.server) { + const setCookieValues = response.headers.getSetCookie ? response.headers.getSetCookie() : [response.headers.get('set-cookie')] + if (setCookieValues && nuxt.ssrContext) { + for (const value of setCookieValues) { + if (!value) { + continue + } + appendHeader(nuxt.ssrContext.event, 'set-cookie', value) } - appendHeader(nuxt.ssrContext.event, 'set-cookie', value) } } - } - data.value = isNonEmptyObject(sessionData) ? sessionData : null - loading.value = false + data.value = isNonEmptyObject(sessionData) ? sessionData : null + loading.value = false - if (required && status.value === 'unauthenticated') { - return onUnauthenticated() - } + if (required && status.value === 'unauthenticated') { + return onUnauthenticated() + } - return sessionData - }, - onRequest: ({ options }) => { - lastRefreshedAt.value = new Date() + return sessionData + }, + onRequest: ({ options }) => { + lastRefreshedAt.value = new Date() - options.params = { - ...options.params, - callbackUrl: callbackUrl || callbackUrlFallback - } - }, - onRequestError: onError, - onResponseError: onError, - headers - }) -} -function getSessionWithNuxt(nuxt: NuxtApp) { - return callWithNuxt(nuxt, getSession) -} + options.params = { + ...options.params, + callbackUrl: callbackUrl || callbackUrlFallback + } + }, + onRequestError: onError, + onResponseError: onError, + headers + }) + } + function getSessionWithNuxt(nuxt: NuxtApp) { + return callWithNuxt(nuxt, getSession) + } -/** - * Sign out the current user. - * - * @param options - Options for sign out, e.g., to `redirect` the user to a specific page after sign out has completed - */ -const signOut: SignOutFunc = async (options) => { - const nuxt = useNuxtApp() - const runtimeConfig = useRuntimeConfig() + /** + * Sign out the current user. + * + * @param options - Options for sign out, e.g., to `redirect` the user to a specific page after sign out has completed + */ + async function signOut(options?: SignOutOptions) { + const { callbackUrl: userCallbackUrl, redirect = true } = options ?? {} + const csrfToken = await getCsrfTokenWithNuxt(nuxt) + + // Determine the correct callback URL + const callbackUrl = await determineCallbackUrl( + runtimeConfig.public.auth, + userCallbackUrl, + true + ) + + if (!csrfToken) { + throw createError({ statusCode: 400, statusMessage: 'Could not fetch CSRF Token for signing out' }) + } - const { callbackUrl: userCallbackUrl, redirect = true } = options ?? {} - const csrfToken = await getCsrfTokenWithNuxt(nuxt) + const signoutData = await _fetch<{ url: string }>(nuxt, '/signout', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(await getRequestHeaders(nuxt)) + }, + onRequest: ({ options }) => { + options.body = new URLSearchParams({ + csrfToken: csrfToken as string, + callbackUrl, + json: 'true' + }) + } + }).catch(error => error.data) - // Determine the correct callback URL - const callbackUrl = await determineCallbackUrl( - runtimeConfig.public.auth, - userCallbackUrl, - true - ) + if (redirect) { + const url = signoutData.url ?? callbackUrl + return navigateToAuthPageWN(nuxt, url) + } - if (!csrfToken) { - throw createError({ statusCode: 400, statusMessage: 'Could not fetch CSRF Token for signing out' }) + await getSessionWithNuxt(nuxt) + return signoutData } - const signoutData = await _fetch<{ url: string }>(nuxt, '/signout', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - ...(await getRequestHeaders(nuxt)) - }, - onRequest: ({ options }) => { - options.body = new URLSearchParams({ - csrfToken: csrfToken as string, - callbackUrl, - json: 'true' - }) + /** + * Utilities to make nested async composable calls play nicely with nuxt. + * + * Calling nested async composable can lead to "nuxt instance unavailable" errors. See more details here: https://github.com/nuxt/framework/issues/5740#issuecomment-1229197529. To resolve this we can manually ensure that the nuxt-context is set. This module contains `callWithNuxt` helpers for some of the methods that are frequently called in nested `useAuth` composable calls. + */ + async function getRequestHeaders(nuxt: NuxtApp, includeCookie = true): Promise<{ cookie?: string, host?: string }> { + // `useRequestHeaders` is sync, so we narrow it to the awaited return type here + const headers = await callWithNuxt(nuxt, () => useRequestHeaders(['cookie', 'host'])) + if (includeCookie && headers.cookie) { + return headers } - }).catch(error => error.data) - - if (redirect) { - const url = signoutData.url ?? callbackUrl - return navigateToAuthPageWN(nuxt, url) + return { host: headers.host } } - await getSessionWithNuxt(nuxt) - return signoutData -} - -interface UseAuthReturn extends CommonUseAuthReturn { - getCsrfToken: typeof getCsrfToken - getProviders: typeof getProviders -} -export function useAuth(): UseAuthReturn { - const { - data, - status, - lastRefreshedAt - } = useAuthState() + /** + * Get the current Cross-Site Request Forgery token. + * + * You can use this to pass along for certain requests, most of the time you will not need it. + */ + async function getCsrfToken() { + const headers = await getRequestHeaders(nuxt) + return _fetch<{ csrfToken: string }>(nuxt, '/csrf', { headers }).then(response => response.csrfToken) + } + function getCsrfTokenWithNuxt(nuxt: NuxtApp) { + return callWithNuxt(nuxt, getCsrfToken) + } return { status, diff --git a/src/runtime/composables/local/useAuth.ts b/src/runtime/composables/local/useAuth.ts index 44bbb3a9..80b95864 100644 --- a/src/runtime/composables/local/useAuth.ts +++ b/src/runtime/composables/local/useAuth.ts @@ -1,6 +1,6 @@ import { readonly } from 'vue' import type { Ref } from 'vue' -import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignInFunc, SignOutFunc, SignUpOptions } from '../../types' +import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, SignUpOptions } from '../../types' import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../../helpers' import { _fetch } from '../../utils/fetch' import { getRequestURLWN } from '../common/getRequestURL' @@ -8,267 +8,282 @@ import { ERROR_PREFIX } from '../../utils/logger' import { determineCallbackUrl } from '../../utils/callbackUrl' import { formatToken } from './utils/token' import { useAuthState } from './useAuthState' -import type { UseAuthStateReturn } from './useAuthState' -import { callWithNuxt } from '#app/nuxt' // @ts-expect-error - #auth not defined import type { SessionData } from '#auth' import { navigateTo, nextTick, useNuxtApp, useRoute, useRuntimeConfig } from '#imports' -type Credentials = { username?: string, email?: string, password?: string } & Record +interface Credentials extends Record { + username?: string + email?: string + password?: string +} -const signIn: SignInFunc = async (credentials, signInOptions, signInParams, signInHeaders) => { - const nuxt = useNuxtApp() +export interface SignInFunc> { + ( + credentials: Credentials, + signInOptions?: SecondarySignInOptions, + paramsOptions?: Record, + headersOptions?: Record + ): Promise +} + +export interface SignUpFunc> { + (credentials: Credentials, signUpOptions?: SignUpOptions): Promise +} + +export interface SignOutFunc { + (options?: SignOutOptions): Promise +} + +/** + * Returns an extended version of CommonUseAuthReturn with local-provider specific data + * + * @remarks + * The returned value of `refreshToken` will always be `null` if `refresh.isEnabled` is `false` + */ +interface UseAuthReturn extends CommonUseAuthReturn { + signUp: SignUpFunc + token: Readonly> + refreshToken: Readonly> +} +export function useAuth(): UseAuthReturn { + const nuxt = useNuxtApp() const runtimeConfig = useRuntimeConfig() const config = useTypedBackendConfig(runtimeConfig, 'local') - const { path, method } = config.endpoints.signIn - const response = await _fetch>(nuxt, path, { - method, - body: credentials, - params: signInParams ?? {}, - headers: signInHeaders ?? {} - }) - - const { rawToken, rawRefreshToken } = useAuthState() - - // Extract the access token - const extractedToken = jsonPointerGet(response, config.token.signInResponseTokenPointer) - 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)}` - ) - return - } - rawToken.value = extractedToken - // Extract the refresh token if enabled - if (config.refresh.isEnabled) { - const refreshTokenPointer = config.refresh.token.signInResponseRefreshTokenPointer + const { + data, + status, + lastRefreshedAt, + loading, + token, + refreshToken, + rawToken, + rawRefreshToken, + _internal + } = useAuthState() + + async function signIn>( + credentials: Credentials, + signInOptions?: SecondarySignInOptions, + signInParams?: Record, + signInHeaders?: Record + ): Promise { + const { path, method } = config.endpoints.signIn + const response = await _fetch(nuxt, path, { + method, + body: credentials, + params: signInParams ?? {}, + headers: signInHeaders ?? {} + }) + + if (typeof response !== 'object' || response === null) { + console.error(`${ERROR_PREFIX} signIn returned non-object value`) + return + } - const extractedRefreshToken = jsonPointerGet(response, refreshTokenPointer) - if (typeof extractedRefreshToken !== 'string') { + // Extract the access token + const extractedToken = jsonPointerGet(response, config.token.signInResponseTokenPointer) + if (typeof extractedToken !== 'string') { console.error( - `Auth: string token expected, received instead: ${JSON.stringify(extractedRefreshToken)}. ` - + `Tried to find refresh token at ${refreshTokenPointer} in ${JSON.stringify(response)}` + `${ERROR_PREFIX} string token expected, received instead: ${JSON.stringify(extractedToken)}. ` + + `Tried to find token at ${config.token.signInResponseTokenPointer} in ${JSON.stringify(response)}` ) return } - rawRefreshToken.value = extractedRefreshToken - } - - const { redirect = true, external, callGetSession = true } = signInOptions ?? {} + rawToken.value = extractedToken + + // Extract the refresh token if enabled + if (config.refresh.isEnabled) { + const refreshTokenPointer = config.refresh.token.signInResponseRefreshTokenPointer + + const extractedRefreshToken = jsonPointerGet(response, refreshTokenPointer) + if (typeof extractedRefreshToken !== 'string') { + console.error( + `${ERROR_PREFIX} string token expected, received instead: ${JSON.stringify(extractedRefreshToken)}. ` + + `Tried to find refresh token at ${refreshTokenPointer} in ${JSON.stringify(response)}` + ) + return + } + rawRefreshToken.value = extractedRefreshToken + } - if (callGetSession) { - await nextTick(getSession) - } + const { redirect = true, external, callGetSession = true } = signInOptions ?? {} - if (redirect) { - let callbackUrl = signInOptions?.callbackUrl - if (typeof callbackUrl === 'undefined') { - const redirectQueryParam = useRoute()?.query?.redirect - callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString()) + if (callGetSession) { + await nextTick(getSession) } - return navigateTo(callbackUrl, { external }) - } - - return response -} + if (redirect) { + let callbackUrl = signInOptions?.callbackUrl + if (typeof callbackUrl === 'undefined') { + const redirectQueryParam = useRoute()?.query?.redirect + callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString()) + } -const signOut: SignOutFunc = async (signOutOptions) => { - const nuxt = useNuxtApp() - const runtimeConfig = useRuntimeConfig() - const config = useTypedBackendConfig(runtimeConfig, 'local') - const { data, token, rawToken, refreshToken, rawRefreshToken }: UseAuthStateReturn = await callWithNuxt(nuxt, useAuthState) - - const signOutConfig = config.endpoints.signOut - - let headers - let body - if (signOutConfig) { - headers = new Headers({ [config.token.headerName]: token.value } as HeadersInit) - // If the refresh provider is used, include the refreshToken in the body - if (config.refresh.isEnabled && ['post', 'put', 'patch', 'delete'].includes(signOutConfig.method.toLowerCase())) { - // This uses refresh token pointer as we are passing `refreshToken` - const signoutRequestRefreshTokenPointer = config.refresh.token.refreshRequestTokenPointer - body = objectFromJsonPointer(signoutRequestRefreshTokenPointer, refreshToken.value) + await navigateTo(callbackUrl, { external }) + return } - } - - data.value = null - rawToken.value = null - rawRefreshToken.value = null - let res - if (signOutConfig) { - const { path, method } = signOutConfig - res = await _fetch(nuxt, path, { method, headers, body }) + return response } - const { redirect = true, external } = signOutOptions ?? {} - - if (redirect) { - let callbackUrl = signOutOptions?.callbackUrl - if (typeof callbackUrl === 'undefined') { - const redirectQueryParam = useRoute()?.query?.redirect - callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString(), true) + async function signOut(signOutOptions?: SignOutOptions): Promise { + const signOutConfig = config.endpoints.signOut + + let headers + let body + if (signOutConfig) { + headers = new Headers({ [config.token.headerName]: token.value } as HeadersInit) + // If the refresh provider is used, include the refreshToken in the body + if (config.refresh.isEnabled && ['post', 'put', 'patch', 'delete'].includes(signOutConfig.method.toLowerCase())) { + // This uses refresh token pointer as we are passing `refreshToken` + const signoutRequestRefreshTokenPointer = config.refresh.token.refreshRequestTokenPointer + body = objectFromJsonPointer(signoutRequestRefreshTokenPointer, refreshToken.value) + } } - await navigateTo(callbackUrl, { external }) - } - return res -} + data.value = null + rawToken.value = null + rawRefreshToken.value = null -async function getSession(getSessionOptions?: GetSessionOptions): Promise { - const nuxt = useNuxtApp() + let res: T | undefined + if (signOutConfig) { + const { path, method } = signOutConfig + res = await _fetch(nuxt, path, { method, headers, body }) + } - const config = useTypedBackendConfig(useRuntimeConfig(), 'local') - const { path, method } = config.endpoints.getSession - const { data, loading, lastRefreshedAt, rawToken, token: tokenState, _internal } = useAuthState() + const { redirect = true, external } = signOutOptions ?? {} - let token = tokenState.value - // For cached responses, return the token directly from the cookie - token ??= formatToken(_internal.rawTokenCookie.value, config) + if (redirect) { + let callbackUrl = signOutOptions?.callbackUrl + if (typeof callbackUrl === 'undefined') { + const redirectQueryParam = useRoute()?.query?.redirect + callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString(), true) + } + await navigateTo(callbackUrl, { external }) + } - if (!token && !getSessionOptions?.force) { - loading.value = false - return + return res } - const headers = new Headers(token ? { [config.token.headerName]: token } as HeadersInit : undefined) + async function getSession(getSessionOptions?: GetSessionOptions): Promise { + const { path, method } = config.endpoints.getSession - loading.value = true - try { - const result = await _fetch(nuxt, path, { method, headers }) - const { dataResponsePointer: sessionDataResponsePointer } = config.session - data.value = jsonPointerGet(result, sessionDataResponsePointer) - } - catch (err) { - if (!data.value && err instanceof Error) { - console.error(`Session: unable to extract session, ${err.message}`) + let tokenValue = token.value + // For cached responses, return the token directly from the cookie + tokenValue ??= formatToken(_internal.rawTokenCookie.value, config) + + if (!tokenValue && !getSessionOptions?.force) { + loading.value = false + return } - // Clear all data: Request failed so we must not be authenticated - data.value = null - rawToken.value = null - } - loading.value = false - lastRefreshedAt.value = new Date() + const headers = new Headers(tokenValue ? { [config.token.headerName]: tokenValue } as HeadersInit : undefined) - const { required = false, callbackUrl, onUnauthenticated, external } = getSessionOptions ?? {} - if (required && data.value === null) { - if (onUnauthenticated) { - return onUnauthenticated() + loading.value = true + try { + const result = await _fetch(nuxt, path, { method, headers }) + const { dataResponsePointer: sessionDataResponsePointer } = config.session + data.value = jsonPointerGet(result, sessionDataResponsePointer) + } + catch (err) { + if (!data.value && err instanceof Error) { + console.error(`Session: unable to extract session, ${err.message}`) + } + + // Clear all data: Request failed so we must not be authenticated + data.value = null + rawToken.value = null + } + loading.value = false + lastRefreshedAt.value = new Date() + + const { required = false, callbackUrl, onUnauthenticated, external } = getSessionOptions ?? {} + if (required && data.value === null) { + if (onUnauthenticated) { + return onUnauthenticated() + } + await navigateTo(callbackUrl ?? await getRequestURLWN(nuxt), { external }) } - await navigateTo(callbackUrl ?? await getRequestURLWN(nuxt), { external }) - } - return data.value -} + return data.value + } -async function signUp(credentials: Credentials, signInOptions?: SecondarySignInOptions, signUpOptions?: SignUpOptions): Promise { - const nuxt = useNuxtApp() - const runtimeConfig = useRuntimeConfig() - const config = useTypedBackendConfig(runtimeConfig, 'local') + async function signUp(credentials: Credentials, signUpOptions?: SignUpOptions): Promise { + const signUpEndpoint = config.endpoints.signUp - const signUpEndpoint = config.endpoints.signUp + if (!signUpEndpoint) { + console.warn(`${ERROR_PREFIX} provider.endpoints.signUp is disabled.`) + return + } - if (!signUpEndpoint) { - console.warn(`${ERROR_PREFIX} provider.endpoints.signUp is disabled.`) - return - } + const { path, method } = signUpEndpoint - const { path, method } = signUpEndpoint + // Holds result from fetch to be returned if signUpOptions?.preventLoginFlow is true + const result = await _fetch(nuxt, path, { + method, + body: credentials + }) - // Holds result from fetch to be returned if signUpOptions?.preventLoginFlow is true - const result = await _fetch(nuxt, path, { - method, - body: credentials - }) + if (signUpOptions?.preventLoginFlow) { + return result + } - if (signUpOptions?.preventLoginFlow) { - return result + return signIn(credentials, signUpOptions) } - return signIn(credentials, signInOptions) -} + async function refresh(getSessionOptions?: GetSessionOptions) { + // Only refresh the session if the refresh logic is not enabled + if (!config.refresh.isEnabled) { + return getSession(getSessionOptions) + } -async function refresh(getSessionOptions?: GetSessionOptions) { - const nuxt = useNuxtApp() - const config = useTypedBackendConfig(useRuntimeConfig(), 'local') + const { path, method } = config.refresh.endpoint + const refreshRequestTokenPointer = config.refresh.token.refreshRequestTokenPointer - // Only refresh the session if the refresh logic is not enabled - if (!config.refresh.isEnabled) { - return getSession(getSessionOptions) - } + const headers = new Headers({ + [config.token.headerName]: token.value + } as HeadersInit) - const { path, method } = config.refresh.endpoint - const refreshRequestTokenPointer = config.refresh.token.refreshRequestTokenPointer - - const { refreshToken, token, rawToken, rawRefreshToken, lastRefreshedAt } = useAuthState() - - const headers = new Headers({ - [config.token.headerName]: token.value - } as HeadersInit) - - const response = await _fetch>(nuxt, path, { - method, - headers, - body: objectFromJsonPointer(refreshRequestTokenPointer, refreshToken.value) - }) - - // Extract the new token from the refresh response - 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 ${tokenPointer} in ${JSON.stringify(response)}` - ) - return - } + const response = await _fetch>(nuxt, path, { + method, + headers, + body: objectFromJsonPointer(refreshRequestTokenPointer, refreshToken.value) + }) - if (!config.refresh.refreshOnlyToken) { - const refreshTokenPointer = config.refresh.token.signInResponseRefreshTokenPointer - const extractedRefreshToken = jsonPointerGet(response, refreshTokenPointer) - if (typeof extractedRefreshToken !== 'string') { + // Extract the new token from the refresh response + 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(extractedRefreshToken)}. ` - + `Tried to find refresh token at ${refreshTokenPointer} in ${JSON.stringify(response)}` + `Auth: string token expected, received instead: ${JSON.stringify(extractedToken)}. ` + + `Tried to find token at ${tokenPointer} in ${JSON.stringify(response)}` ) return } - rawRefreshToken.value = extractedRefreshToken - } - - rawToken.value = extractedToken - lastRefreshedAt.value = new Date() + if (!config.refresh.refreshOnlyToken) { + const refreshTokenPointer = config.refresh.token.signInResponseRefreshTokenPointer + const extractedRefreshToken = jsonPointerGet(response, refreshTokenPointer) + if (typeof extractedRefreshToken !== 'string') { + console.error( + `Auth: string token expected, received instead: ${JSON.stringify(extractedRefreshToken)}. ` + + `Tried to find refresh token at ${refreshTokenPointer} in ${JSON.stringify(response)}` + ) + return + } + + rawRefreshToken.value = extractedRefreshToken + } - await nextTick() - return getSession(getSessionOptions) -} + rawToken.value = extractedToken + lastRefreshedAt.value = new Date() -/** - * Returns an extended version of CommonUseAuthReturn with local-provider specific data - * - * @remarks - * The returned value `refreshToken` will always be `null` if `refresh.isEnabled` is `false` - */ -interface UseAuthReturn extends CommonUseAuthReturn { - signUp: typeof signUp - token: Readonly> - refreshToken: Readonly> -} -export function useAuth(): UseAuthReturn { - const { - data, - status, - lastRefreshedAt, - token, - refreshToken - } = useAuthState() + await nextTick() + return getSession(getSessionOptions) + } return { status, diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 2a4841e8..e5f21f85 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -537,13 +537,18 @@ export interface RouteOptions { export type SessionLastRefreshedAt = Date | undefined export type SessionStatus = 'authenticated' | 'unauthenticated' | 'loading' type WrappedSessionData = Ref -export interface CommonUseAuthReturn { + +export interface GetSessionFunc { + (getSessionOptions?: GetSessionOptions): Promise +} + +export interface CommonUseAuthReturn { data: Readonly> lastRefreshedAt: Readonly> status: ComputedRef signIn: SignIn signOut: SignOut - getSession: GetSession + getSession: GetSessionFunc refresh: () => Promise } @@ -564,6 +569,7 @@ export interface SecondarySignInOptions extends Record { callbackUrl?: string /** * Whether to redirect users after the method succeeded. + * Note that redirect will always happen on a failure for `authjs` provider. * * @default true */ @@ -597,7 +603,7 @@ export interface SignOutOptions { external?: boolean } -export type GetSessionOptions = Partial<{ +export interface GetSessionOptions { required?: boolean callbackUrl?: string external?: boolean @@ -608,16 +614,7 @@ export type GetSessionOptions = Partial<{ * @default false */ force?: boolean -}> - -// TODO: These types could be nicer and more general, or located within `useAuth` files and more specific -export type SignOutFunc = (options?: SignOutOptions) => Promise -export type SignInFunc = ( - primaryOptions: PrimarySignInOptions, - signInOptions?: SecondarySignInOptions, - paramsOptions?: Record, - headersOptions?: Record -) => Promise +} export interface ModuleOptionsNormalized extends ModuleOptions { isEnabled: boolean