Skip to content

Commit

Permalink
feat(#673, #811, #848): back-port authjs migration
Browse files Browse the repository at this point in the history
  • Loading branch information
phoenix-ru committed Aug 8, 2024
1 parent 4729b1f commit 93e96be
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 156 deletions.
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

0 comments on commit 93e96be

Please sign in to comment.