From 41e02326039c28578c2e369d6b0441acfd0fbb6b Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Tue, 4 Nov 2025 12:11:15 +0100 Subject: [PATCH] feat(auth0-fastify): use AsyncLocalStorage to track FastifyRequest and FastifyReply --- examples/example-fastify-web/src/index.ts | 8 +- packages/auth0-fastify/src/index.ts | 19 +++- .../src/store/request-context.ts | 67 +++++++++++ packages/auth0-fastify/src/types.ts | 6 +- packages/auth0-fastify/src/utils.ts | 105 ++++++++++++++++++ 5 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 packages/auth0-fastify/src/store/request-context.ts diff --git a/examples/example-fastify-web/src/index.ts b/examples/example-fastify-web/src/index.ts index 7894ea4..71e5aa6 100644 --- a/examples/example-fastify-web/src/index.ts +++ b/examples/example-fastify-web/src/index.ts @@ -37,7 +37,7 @@ fastify.register(fastifyAuth0, { fastify.get('/', async (request, reply) => { - const user = await fastify.auth0Client!.getUser({ request, reply }); + const user = await fastify.auth0Client!.getUser(); return reply.viewAsync('index.ejs', { isLoggedIn: !!user, user: user }); }); @@ -46,7 +46,7 @@ async function hasSessionPreHandler( request: FastifyRequest, reply: FastifyReply ) { - const session = await fastify.auth0Client!.getSession({ request, reply }); + const session = await fastify.auth0Client!.getSession(); if (!session) { reply.redirect(`/auth/login?returnTo=${request.url}`); @@ -56,7 +56,7 @@ async function hasSessionPreHandler( fastify.get( '/public', async (request, reply) => { - const user = await fastify.auth0Client!.getUser({ request, reply }); + const user = await fastify.auth0Client!.getUser(); return reply.viewAsync('public.ejs', { isLoggedIn: !!user, @@ -71,7 +71,7 @@ fastify.get( preHandler: hasSessionPreHandler, }, async (request, reply) => { - const user = await fastify.auth0Client!.getUser({ request, reply }); + const user = await fastify.auth0Client!.getUser(); return reply.viewAsync('private.ejs', { isLoggedIn: !!user, diff --git a/packages/auth0-fastify/src/index.ts b/packages/auth0-fastify/src/index.ts index 2e661a6..56ce2f9 100644 --- a/packages/auth0-fastify/src/index.ts +++ b/packages/auth0-fastify/src/index.ts @@ -9,11 +9,12 @@ import type { import fp from 'fastify-plugin'; import { CookieTransactionStore, ServerClient, StatelessStateStore, StatefulStateStore } from '@auth0/auth0-server-js'; import type { SessionConfiguration, SessionStore, StoreOptions } from './types.js'; -import { createRouteUrl, toSafeRedirect } from './utils.js'; +import { createRouteUrl, toFastifyInstance, toSafeRedirect } from './utils.js'; import { FastifyCookieHandler } from './store/fastify-cookie-handler.js'; export * from './types.js'; export { CookieTransactionStore } from '@auth0/auth0-server-js'; +import { runWithContext } from './store/request-context.js'; declare module 'fastify' { /** @@ -26,7 +27,7 @@ declare module 'fastify' { interface FastifyInstance< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, - RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, > { /** * The Auth0 Server Client instance attached to the Fastify instance. @@ -41,7 +42,7 @@ declare module 'fastify' { export interface Auth0FastifyOptions< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, - RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, > { domain: string; clientId: string; @@ -86,7 +87,7 @@ export interface Auth0FastifyOptions< export default fp(async function auth0Fastify< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, - RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, >( fastify: FastifyInstance, options: Auth0FastifyOptions @@ -321,5 +322,13 @@ export default fp(async function auth0Fastify< } } - fastify.decorate('auth0Client', auth0Client); + // We rely on AsyncLocalStorage to store `FastifyRequest` and `FastifyReply` objects per request. + // This ensures we simplify the public API, as consumers no longer need to pass these instances to the methods. + fastify.addHook('onRequest', (request, reply, done) => { + // Run the rest of the request lifecycle (all subsequent hooks, + // handlers, and replies) inside the AsyncLocalStorage context. + runWithContext({ request, reply }, () => done()); + }); + + fastify.decorate('auth0Client', toFastifyInstance(auth0Client)); }); diff --git a/packages/auth0-fastify/src/store/request-context.ts b/packages/auth0-fastify/src/store/request-context.ts new file mode 100644 index 0000000..f681216 --- /dev/null +++ b/packages/auth0-fastify/src/store/request-context.ts @@ -0,0 +1,67 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { StoreOptions } from '../types.js'; +import { RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase } from 'fastify'; + +/** + * Context containing Express request and response objects. + * Available within the AsyncLocalStorage scope established by the auth0 middleware. + */ +export type RequestContext = StoreOptions; + +/** + * AsyncLocalStorage instance for storing request context. + * @internal + */ +const asyncLocalStorage = new AsyncLocalStorage(); + +/** + * Runs a callback within an AsyncLocalStorage context containing the request and response. + * This establishes the context for the entire request lifecycle. + * + * @param request - Express request object + * @param response - Express response object + * @param callback - Function to execute within the context + * @returns The result of the callback + * + * @example + * ```typescript + * app.use((req, res, next) => { + * runWithContext(req, res, () => next()); + * }); + * ``` + */ +export function runWithContext(context: TRequestContext, callback: () => T): T { + return asyncLocalStorage.run(context, callback); +} + +/** + * Retrieves the current request context from AsyncLocalStorage. + * + * @returns The current RequestContext + * @throws {Error} If called outside of a request context + * + * @example + * ```typescript + * const { request, response } = getRequestContext(); + * ``` + */ +export function getRequestContext< + TRawServer extends RawServerBase, + TRawRequest extends RawRequestDefaultExpression, + TRawReply extends RawReplyDefaultExpression, +>(): StoreOptions { + const context = asyncLocalStorage.getStore(); + + if (!context) { + throw new Error( + 'Request context not available. This error typically occurs when:\n' + + '1. Client methods are called outside of a request handler\n' + + '2. The auth0 SDK has not been initialized\n' + + '3. AsyncLocalStorage context was lost in an async operation\n\n' + + 'Ensure you are calling client methods within a Fastify request handler ' + + 'and the auth0 SDK is properly configured.' + ); + } + + return context as StoreOptions; +} diff --git a/packages/auth0-fastify/src/types.ts b/packages/auth0-fastify/src/types.ts index 1b1d986..bd9b7ac 100644 --- a/packages/auth0-fastify/src/types.ts +++ b/packages/auth0-fastify/src/types.ts @@ -12,7 +12,7 @@ import { LogoutTokenClaims, StateData } from '@auth0/auth0-server-js'; /** * Options for accessing the Fastify request and reply objects. * These are used in store implementations to interact with cookies and sessions. - * + * * FastifyInstance is a generic interface itself, whose generics represent the underlying server, request and reply types. * By including these in the StoreOptions generics, we ensure that `StoreOptions` is aware of the underlying server type (e.g., HTTP/1.1, HTTP/2, etc.). * @@ -21,7 +21,7 @@ import { LogoutTokenClaims, StateData } from '@auth0/auth0-server-js'; export interface StoreOptions< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, - RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, > { request: FastifyRequest; reply: FastifyReply; @@ -30,7 +30,7 @@ export interface StoreOptions< export interface SessionStore< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, - RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, > { delete(identifier: string): Promise; set(identifier: string, stateData: StateData): Promise; diff --git a/packages/auth0-fastify/src/utils.ts b/packages/auth0-fastify/src/utils.ts index 02f7567..77e3ff0 100644 --- a/packages/auth0-fastify/src/utils.ts +++ b/packages/auth0-fastify/src/utils.ts @@ -1,3 +1,16 @@ +import { + AccessTokenForConnectionOptions, + LoginBackchannelOptions, + LogoutOptions, + ServerClient, + StartInteractiveLoginOptions, + StartLinkUserOptions, + StartUnlinkUserOptions, +} from '@auth0/auth0-server-js'; +import { RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase, RawServerDefault } from 'fastify'; +import { StoreOptions } from './types.js'; +import { getRequestContext } from './store/request-context.js'; + /** * Ensures the value has a trailing slash. * If it does not, it will append one. @@ -49,3 +62,95 @@ export function toSafeRedirect(dangerousRedirect: string, safeBaseUrl: string): return undefined; } + +/** + * Converts a ServerClient to a FastifyInstance-bound client. + * + * This allows using the client methods without explicitly passing StoreOptions, + * as they will be automatically retrieved from the FastifyInstance's AsyncLocalStorage context (`requestContext`). + * @param serverClient The server client. + * @returns The FastifyInstance-bound client. + */ +export function toFastifyInstance< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, +>(serverClient: ServerClient>) { + return { + startInteractiveLogin: ( + options?: StartInteractiveLoginOptions, + storeOptions?: StoreOptions + ) => { + return serverClient?.startInteractiveLogin( + options, + storeOptions ?? getRequestContext() + ); + }, + completeInteractiveLogin: (url: URL, storeOptions?: StoreOptions) => { + return serverClient?.completeInteractiveLogin( + url, + storeOptions ?? getRequestContext() + ); + }, + getUser: (storeOptions?: StoreOptions) => { + return serverClient?.getUser(storeOptions ?? getRequestContext()); + }, + getSession: (storeOptions?: StoreOptions) => { + return serverClient?.getSession(storeOptions ?? getRequestContext()); + }, + getAccessToken: (storeOptions?: StoreOptions) => { + return serverClient?.getAccessToken(storeOptions ?? getRequestContext()); + }, + getAccessTokenForConnection: ( + options: AccessTokenForConnectionOptions, + storeOptions?: StoreOptions + ) => { + return serverClient?.getAccessTokenForConnection( + options, + storeOptions ?? getRequestContext() + ); + }, + loginBackchannel: ( + options: LoginBackchannelOptions, + storeOptions?: StoreOptions + ) => { + return serverClient?.loginBackchannel( + options, + storeOptions ?? getRequestContext() + ); + }, + logout: (options: LogoutOptions, storeOptions?: StoreOptions) => { + return serverClient?.logout(options, storeOptions ?? getRequestContext()); + }, + handleBackchannelLogout: (logoutToken: string, storeOptions?: StoreOptions) => { + return serverClient?.handleBackchannelLogout( + logoutToken, + storeOptions ?? getRequestContext() + ); + }, + startLinkUser: (options: StartLinkUserOptions, storeOptions?: StoreOptions) => { + return serverClient.startLinkUser(options, storeOptions ?? getRequestContext()); + }, + completeLinkUser: (url: URL, storeOptions?: StoreOptions) => { + return serverClient.completeLinkUser( + url, + storeOptions ?? getRequestContext() + ); + }, + startUnlinkUser: ( + options: StartUnlinkUserOptions, + storeOptions?: StoreOptions + ) => { + return serverClient.startUnlinkUser( + options, + storeOptions ?? getRequestContext() + ); + }, + completeUnlinkUser: (url: URL, storeOptions?: StoreOptions) => { + return serverClient.completeUnlinkUser( + url, + storeOptions ?? getRequestContext() + ); + }, + } as ServerClient>; +}