Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#673, #523, #848): back-port authjs migration #849

Merged
merged 8 commits into from
Aug 20, 2024
318 changes: 183 additions & 135 deletions src/runtime/server/services/authjs/nuxtAuthHandler.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,30 @@
import type { IncomingHttpHeaders } from 'http'
import { getQuery, setCookie, readBody, sendRedirect, eventHandler, parseCookies, createError, isMethod, getHeaders, getResponseHeader, setResponseHeader } from 'h3'
import { getQuery, setCookie, readBody, sendRedirect, eventHandler, parseCookies, createError, isMethod, getHeaders, getResponseHeader, setResponseHeader, getRequestHost, getRequestProtocol } from 'h3'
import type { H3Event } from 'h3'
import type { CookieSerializeOptions } from 'cookie-es'

import { AuthHandler } from 'next-auth/core'
import { getToken as nextGetToken } from 'next-auth/jwt'
import type { RequestInternal } from 'next-auth/core'
import { getToken as authjsGetToken } from 'next-auth/jwt'
import type { RequestInternal, ResponseInternal } from 'next-auth/core'
import type { AuthAction, AuthOptions, Session } from 'next-auth'
import type { GetTokenParams } from 'next-auth/jwt'

import { defu } from 'defu'
import { joinURL } from 'ufo'
import { ERROR_MESSAGES } from '../errors'
import { isNonEmptyObject } from '../../../utils/checkSessionResult'
import { getServerOrigin, getRequestURLFromRequest } from '../utils'
import { getServerOrigin } from '../utils'
import { useTypedBackendConfig } from '../../../helpers'

import { useRuntimeConfig } from '#imports'

let preparedAuthHandler: ReturnType<typeof eventHandler> | undefined
let preparedAuthjsHandler: ((req: RequestInternal) => Promise<ResponseInternal>) | undefined
let usedSecret: string | undefined
const SUPPORTED_ACTIONS: AuthAction[] = ['providers', 'session', 'csrf', 'signin', 'signout', 'callback', 'verify-request', 'error', '_log']

const useConfig = () => useTypedBackendConfig(useRuntimeConfig(), 'authjs')

/**
* Parse a body if the request method is supported, return `undefined` otherwise.

* @param event H3Event event to read body of
*/
const readBodyForNext = async (event: H3Event) => {
let body: any

if (isMethod(event, ['PATCH', 'POST', 'PUT', 'DELETE'])) {
body = await readBody(event)
}
return body
}

/**
* Get action and optional provider from a request.
*
* E.g., with a request like `/api/signin/github` get the action `signin` with the provider `github`
*/
const parseActionAndProvider = ({ context }: H3Event): { action: AuthAction, providerId: string | undefined } => {
const params: string[] | undefined = context.params?._?.split('/')

if (!params || ![1, 2].includes(params.length)) {
throw createError({ statusCode: 400, statusMessage: `Invalid path used for auth-endpoint. Supply either one path parameter (e.g., \`/api/auth/session\`) or two (e.g., \`/api/auth/signin/github\` after the base path (in previous examples base path was: \`/api/auth/\`. Received \`${params}\`` })
}

const [unvalidatedAction, providerId] = params

// Get TS to correctly infer the type of `unvalidatedAction`
const action = SUPPORTED_ACTIONS.find(action => action === unvalidatedAction)
if (!action) {
throw createError({ statusCode: 400, statusMessage: `Called endpoint with unsupported action ${unvalidatedAction}. Only the following actions are supported: ${SUPPORTED_ACTIONS.join(', ')}` })
}

return { action, providerId }
}

/** Setup the nuxt (next) auth event handler, based on the passed in options */
export const NuxtAuthHandler = (nuxtAuthOptions?: AuthOptions) => {
export function NuxtAuthHandler (nuxtAuthOptions?: AuthOptions) {
const isProduction = process.env.NODE_ENV === 'production'
const trustHostUserPreference = useTypedBackendConfig(useRuntimeConfig(), 'authjs').trustHost

usedSecret = nuxtAuthOptions?.secret
if (!usedSecret) {
Expand All @@ -79,73 +40,33 @@ export const NuxtAuthHandler = (nuxtAuthOptions?: AuthOptions) => {
secret: usedSecret,
logger: undefined,
providers: [],
trustHost: useConfig().trustHost
})

/**
* Generate a NextAuth.js internal request object that we can pass into the NextAuth.js
* handler. This method will either try to fill all fields for a request that targets
* the auth-REST API or return a minimal internal request to support server-side
* session fetching for requests with arbitrary, non auth-REST API
* targets (set via: `event.context.checkSessionOnNonAuthRequest = true`)
*
* @param event H3Event event to transform into `RequestInternal`
*/
const getInternalNextAuthRequestData = async (event: H3Event): Promise<RequestInternal> => {
const nextRequest: Omit<RequestInternal, 'action'> = {
host: getRequestURLFromRequest(event, { trustHost: useConfig().trustHost }),
body: undefined,
cookies: parseCookies(event),
query: undefined,
headers: getHeaders(event),
method: event.method,
providerId: undefined,
error: undefined
}

// Setting `event.context.checkSessionOnNonAuthRequest = true` allows callers of `authHandler`.
// We can use this to check session status on the server-side.
//
// When doing this, most other data is not required, e.g., we do not need to parse the body. For this reason,
// we return the minimum required data for session checking.
if (event.context.checkSessionOnNonAuthRequest === true) {
return {
...nextRequest,
method: 'GET',
action: 'session'
}
}
// SAFETY: We trust host here because `getRequestURLFromH3Event` is responsible for producing a trusted URL
trustHost: true,

// Figure out what action, providerId (optional) and error (optional) of the NextAuth.js lib is targeted
const query = getQuery(event)
const { action, providerId } = parseActionAndProvider(event)
const error = query.error
if (Array.isArray(error)) {
throw createError({ statusCode: 400, statusMessage: 'Error query parameter can only appear once' })
}
// AuthJS uses `/auth` as default, but we rely on `/api/auth` (same as in previous `next-auth`)
basePath: '/api/auth'

const body = await readBodyForNext(event)
// Uncomment to enable framework-author specific functionality
// raw: raw as typeof raw
})

return {
...nextRequest,
body,
query,
action,
providerId,
error: error ? String(error) : undefined
}
// Save handler so that it can be used in other places
if (preparedAuthjsHandler) {
console.error('You setup the auth handler for a second time - this is likely undesired. Make sure that you only call `NuxtAuthHandler( ... )` once')
}

const handler = eventHandler(async (event: H3Event) => {
preparedAuthjsHandler = (req: RequestInternal) => AuthHandler({ req, options })

return eventHandler(async (event: H3Event) => {
const { res } = event.node

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

const nextResult = await AuthHandler({
req: nextRequest,
options
})
// 1.2. Call Authjs
// Safety: `preparedAuthjsHandler` was assigned earlier and never re-assigned
const nextResult = await preparedAuthjsHandler!(nextRequest)

// 2. Set response status, headers, cookies
if (nextResult.status) {
Expand All @@ -155,7 +76,7 @@ export const NuxtAuthHandler = (nuxtAuthOptions?: AuthOptions) => {
nextResult.headers?.forEach(header => appendHeaderDeduped(event, header.key, header.value))

// 3. Return either:
// 3.1 the body directly if no redirect is set:
// 3.1. the body directly if no redirect is set:
if (!nextResult.redirect) {
return nextResult.body
}
Expand All @@ -175,37 +96,52 @@ export const NuxtAuthHandler = (nuxtAuthOptions?: AuthOptions) => {
// 3.3 via a redirect:
return await sendRedirect(event, nextResult.redirect)
})

// Save handler so that it can be used in other places
if (preparedAuthHandler) {
console.warn('You setup the auth handler for a second time - this is likely undesired. Make sure that you only call `NuxtAuthHandler( ... )` once')
}
preparedAuthHandler = handler
return handler
}

export const getServerSession = async (event: H3Event) => {
const authBasePath = useRuntimeConfig().public.auth.computed.pathname
/** Gets session on server-side */
export async function getServerSession (event: H3Event) {
const runtimeConfig = useRuntimeConfig()
const authBasePath = runtimeConfig.public.auth.computed.pathname
const trustHostUserPreference = useTypedBackendConfig(runtimeConfig, 'authjs').trustHost

// avoid running auth middleware on auth middleware (see #186)
if (event.path && event.path.startsWith(authBasePath)) {
return null
}
if (!preparedAuthHandler) {
const headers = getHeaders(event) as HeadersInit

// Edge-case: If no auth-endpoint was called yet, `preparedAuthHandler`-initialization was also not attempted as Nuxt lazily loads endpoints in production-mode. This call gives it a chance to load + initialize the variable. If it fails we still throw. This edge-case has happened to user matijao#7025 on discord.
await $fetch(joinURL(authBasePath, '/session'), { headers }).catch(error => error.data)
if (!preparedAuthHandler) {
const sessionUrlPath = joinURL(authBasePath, '/session')
const headers = getHeaders(event) as HeadersInit
if (!preparedAuthjsHandler) {
// Edge-case: If no auth-endpoint was called yet, `preparedAuthHandler`-initialization was also not attempted as Nuxt lazily loads endpoints in production-mode.
// This call gives it a chance to load + initialize the variable. If it fails we still throw. This edge-case has happened to user matijao#7025 on discord.
await $fetch(sessionUrlPath, { headers }).catch(error => error.data)
if (!preparedAuthjsHandler) {
throw createError({ statusCode: 500, statusMessage: 'Tried to get server session without setting up an endpoint to handle authentication (see https://github.com/sidebase/nuxt-auth#quick-start)' })
}
}

// Run a session check on the event with an arbitrary target endpoint
event.context.checkSessionOnNonAuthRequest = true
const session = await preparedAuthHandler(event)
delete event.context.checkSessionOnNonAuthRequest
// Build a correct endpoint
const sessionUrlBase = getRequestBaseFromH3Event(event, trustHostUserPreference)
const sessionUrl = new URL(sessionUrlPath, sessionUrlBase)

// Create a virtual Request to check the session
const authjsRequest: RequestInternal = {
action: 'session',
method: 'GET',
headers,
body: undefined,
cookies: parseCookies(event),
providerId: undefined,
error: undefined,
host: sessionUrl.origin,
query: Object.fromEntries(sessionUrl.searchParams)
}

// Invoke Auth.js
const authjsResponse = await preparedAuthjsHandler(authjsRequest)

// Get the body of response
const session = authjsResponse.body
if (isNonEmptyObject(session)) {
return session as Session
}
Expand All @@ -216,21 +152,133 @@ export const getServerSession = async (event: H3Event) => {
/**
* Get the decoded JWT token either from cookies or header (both are attempted).
*
* The only change from the original `getToken` implementation is that the `req` is not passed in, in favor of `event` being passed in. See https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken for further documentation.
* The only change from the original `getToken` implementation is that the `req` is not passed in, in favor of `event` being passed in.
* See https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken for further documentation.
*
* @param eventAndOptions The event to get the cookie or authorization header from that contains the JWT Token and options you want to alter token getting behavior.
*/
export function getToken<R extends boolean = false> ({ event, secureCookie, secret, ...rest }: Omit<GetTokenParams<R>, 'req'> & { event: H3Event }) {
return authjsGetToken({
// @ts-expect-error As our request is not a real next-auth request, we pass down only what's required for the method, as per code from https://github.com/nextauthjs/next-auth/blob/8387c78e3fef13350d8a8c6102caeeb05c70a650/packages/next-auth/src/jwt/index.ts#L68
req: {
cookies: parseCookies(event),
headers: getHeaders(event) as IncomingHttpHeaders
},
// see https://github.com/nextauthjs/next-auth/blob/8387c78e3fef13350d8a8c6102caeeb05c70a650/packages/next-auth/src/jwt/index.ts#L73
secureCookie: secureCookie ?? getServerOrigin(event).startsWith('https://'),
secret: secret || usedSecret,
...rest
})
}

/**
* Generate an Auth.js request object that can be passed into the handler.
* This method should only be used for authentication endpoints.
*
* @param eventAndOptions Omit<GetTokenParams, 'req'> & { event: H3Event } The event to get the cookie or authorization header from that contains the JWT Token and options you want to alter token getting behavior.
* @param event H3Event to transform into `RequestInternal`
*/
export const getToken = <R extends boolean = false>({ event, secureCookie, secret, ...rest }: Omit<GetTokenParams<R>, 'req'> & { event: H3Event }) => nextGetToken({
// @ts-expect-error As our request is not a real next-auth request, we pass down only what's required for the method, as per code from https://github.com/nextauthjs/next-auth/blob/8387c78e3fef13350d8a8c6102caeeb05c70a650/packages/next-auth/src/jwt/index.ts#L68
req: {
async function createRequestForAuthjs (event: H3Event, trustHostUserPreference: boolean): Promise<RequestInternal> {
const nextRequest: Omit<RequestInternal, 'action'> = {
host: getRequestURLFromH3Event(event, trustHostUserPreference).origin,
body: undefined,
cookies: parseCookies(event),
headers: getHeaders(event) as IncomingHttpHeaders
},
// see https://github.com/nextauthjs/next-auth/blob/8387c78e3fef13350d8a8c6102caeeb05c70a650/packages/next-auth/src/jwt/index.ts#L73
secureCookie: secureCookie ?? getServerOrigin(event).startsWith('https://'),
secret: secret || usedSecret,
...rest
})
query: undefined,
headers: getHeaders(event),
method: event.method,
providerId: undefined,
error: undefined
}

// Figure out what action, providerId (optional) and error (optional) of the NextAuth.js lib is targeted
const query = getQuery(event)
const { action, providerId } = parseActionAndProvider(event)
const error = query.error
if (Array.isArray(error)) {
throw createError({ statusCode: 400, statusMessage: 'Error query parameter can only appear once' })
}

// Parse a body if the request method is supported, use `undefined` otherwise
const body = isMethod(event, ['PATCH', 'POST', 'PUT', 'DELETE'])
? await readBody(event)
: undefined

return {
...nextRequest,
body,
query,
action,
providerId,
error: error ? String(error) : undefined
}
}

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

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

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

return origin
}
}

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

/**
* Get action and optional provider from a request.
*
* E.g. with a request like `/api/signin/github` get the action `signin` with the provider `github`
*/
function parseActionAndProvider ({ context }: H3Event): { action: AuthAction; providerId: string | undefined} {
const params: string[] | undefined = context.params?._?.split('/')

if (!params || ![1, 2].includes(params.length)) {
throw createError({ statusCode: 400, statusMessage: `Invalid path used for auth-endpoint. Supply either one path parameter (e.g., \`/api/auth/session\`) or two (e.g., \`/api/auth/signin/github\` after the base path (in previous examples base path was: \`/api/auth/\`. Received \`${params}\`` })
}

const [unvalidatedAction, providerId] = params

// Get TS to correctly infer the type of `unvalidatedAction`
const action = SUPPORTED_ACTIONS.find(action => action === unvalidatedAction)
if (!action) {
throw createError({ statusCode: 400, statusMessage: `Called endpoint with unsupported action ${unvalidatedAction}. Only the following actions are supported: ${SUPPORTED_ACTIONS.join(', ')}` })
}

return { action, providerId }
}

/** Adapted from `h3` to fix https://github.com/sidebase/nuxt-auth/issues/523 */
function appendHeaderDeduped (event: H3Event, name: string, value: string) {
Expand Down
Loading