Skip to content

Conversation

@frederikprijck
Copy link
Member

@frederikprijck frederikprijck commented Jan 12, 2026

📋 Changes

Introduces an abstraction layer to decouple authentication logic from Next.js-specific NextRequest/NextResponse types. This refactor enables future support for API routes with Pages Router without duplicating authentication code.

Impact: No breaking changes to external APIs. This is purely an internal refactor - middleware continues to work exactly as before.

Motivation

The current v4 implementation is tightly coupled to Next.js middleware types (NextRequest / NextResponse). Supporting API routes or Pages Router would require duplicating all authentication logic for NextApiRequest / NextApiResponse, or adding some if/else brancing.

This abstraction establishes a foundation for multi-paradigm support through polymorphism rather than code duplication.

Overview

New HTTP layer (src/server/http/):

  • Auth0Request<TRequest> - Abstract base class for request operations
  • Auth0Response<TResponse> - Abstract base class for response operations
  • Auth0NextRequest / Auth0NextResponse - Concrete implementations for middleware
  • Auth0RequestCookies / Auth0ResponseCookies - Unified cookie interfaces

Key design decisions:

  1. Generic type preservation: Auth0Request<NextRequest> maintains full type information through the stack
  2. Mutable response pattern: Response methods mutate internal state.
  3. Cookie abstraction unification: Single interface for both RequestCookies and ReadonlyRequestCookies

Critical Changes & Patterns

1. Handler Abstraction Pattern

All authentication handlers now accept abstracted types instead of concrete Next.js types:

// Before
async handleLogin(req: NextRequest): Promise<NextResponse>

// After
async handleLogin(req: Auth0Request, res: Auth0Response): Promise<Auth0Response>

This enables the same handler logic to work with any request/response implementation (middleware, API routes, route handlers).

2. Response Wrapper & Header Merging

Introduced #unwrapHandler() helper in auth-client.ts that:

  • Calls handlers that now return Auth0Response instead of NextResponse
  • Unwraps the concrete NextResponse from `Auth0Response.res
  • Returns the NextResponse instance.

The response wrapper pattern allows handlers to remain agnostic of the concrete response type while maintaining compatibility with Next.js middleware expectations in a non-breaking way.

3. Session & Transaction Store Refactoring

All storage abstractions now operate on Auth0RequestCookies / Auth0ResponseCookies:

  • AbstractSessionStore - Updated interface for get/set/delete operations
  • StatelessSessionStore - Cookie encoding/chunking logic works with abstractions
  • StatefulSessionStore - Database operations use abstracted cookie interfaces
  • TransactionStore - State parameter handling via abstractions

This enables future storage implementations to work seamlessly across different Next.js contexts.

4. Response

When using NextResponse, we typically need to create a new instance and return it. However, to support Pages Router, which uses NextApiResponse, we know that it will be different in the sense that we will accept a NextApiResponse and call methods on that instance rather than return new instances. Therefore, this PR already ensures we create an Auth0Response in the beginning of all handlers, and returns the underlying Auth0Response.res.

This may be a bit weird looking at it purely from the App Router perspective, but is done to support Pages Router in a follow up PR.

📎 References

N/A

🎯 Testing

  • All existing middleware tests pass without modification
  • New test cases covering abstraction layer behavior

@frederikprijck frederikprijck requested a review from a team as a code owner January 12, 2026 11:50
@frederikprijck frederikprijck force-pushed the feat/middlewareless-phase1 branch from 231133e to c75cc47 Compare January 12, 2026 11:51
@codecov-commenter
Copy link

codecov-commenter commented Jan 12, 2026

Codecov Report

❌ Patch coverage is 90.63745% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.09%. Comparing base (e5d35f2) to head (4154376).
⚠️ Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
src/server/client.ts 40.90% 39 Missing ⚠️
src/server/session/stateless-session-store.ts 50.00% 6 Missing ⚠️
src/server/auth-client.ts 99.19% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2484      +/-   ##
==========================================
- Coverage   91.18%   91.09%   -0.09%     
==========================================
  Files          39       46       +7     
  Lines        4694     4931     +237     
  Branches      980     1027      +47     
==========================================
+ Hits         4280     4492     +212     
- Misses        408      433      +25     
  Partials        6        6              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@frederikprijck frederikprijck force-pushed the feat/middlewareless-phase1 branch from c75cc47 to f34e2c9 Compare January 12, 2026 12:03
method: "GET"
});
authClient.handleLogin = vi.fn();
authClient.handleLogin = vi.fn().mockResolvedValue({});
Copy link
Member Author

@frederikprijck frederikprijck Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original mock forhandleX() is actually not correctly configured. Every handleX() method is configured not to return undefined, while vi.fn() on the left does return undefined.

This worked fine for this test, as we never expected any response and handler() just returned whatever handleX() returns.

However, handleX() now returns an Auth0Response, which handler() now unwraps using #unwrapHandler(). Because of that, handler() expects handleX to return something.

In this case, we return {}, which would mean the unwrapped NextResponse will be undefined, just like the original test.

Comment on lines +1266 to +1272
const auth0Req = new Auth0NextRequest(request);

const auth0Res = new Auth0NextResponse(NextResponse.next());

await authClient.handleLogin(auth0Req, auth0Res);

const response = auth0Res.res;
Copy link
Member Author

@frederikprijck frederikprijck Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original tests call handleX(). In my opinion, handleX is an implementation detail. It's the handler called by handler() for the corresponding route (e.g. /auth/login for handleLogin()). In my opinion, this should have been a private method, and call handler() instead in the tests.

If this test would have called const response = await authClient.handler(request); instead of const response = await authClient.handleLogin(request);, this would have not required any change at all.

Personally, I think we should change this test to go through the handler(), and treat handleX as an implementation detail.

Happy to update the PR, or do a follow up PR. Additionally, also happy to leave the tests as is, as they are public methods, so they aren't configured as the real implementation details I consider them.

Comment on lines +399 to +400
const auth0Req = new Auth0NextRequest(req);
const auth0Res = new Auth0NextResponse(new NextResponse());
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As handler() is our main public API, I ensured this does not require any change. Instead, the first thing it does now is create the Auth0NextRequest instance, holding the original NextRequest instance.

Additionally, it also prepares an Auth0NextResponse, which contains a placeholder response.

This change for Auth0NextResponse is a bit biased by the fact that I know we want to introduce support for NextApiResponse in a next PR. NextResponse and NextApiResponse are very different in usage:

  • NextResponse: Next.js expects you to create and return the instance.
  • NextApiResponse: Next.js gives you the NextApiResponse for the NextApiRequest, and expects you to mutate it.

Because of that knowledge, the Auth0Request and Auth0Response classes already account for this.

Admittedly, this bias comes from the fact that this PR is created by pulling out the Auth0Request, Auth0Response and Middleware / AppRouter specific logic from a single branch that contains the full integration with PagesRouter and AppRouter without using middleware.

const auth0Req = new Auth0NextRequest(req);
const auth0Res = new Auth0NextResponse(new NextResponse());

let { pathname } = auth0Req.getUrl();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We no longer use nextUrl, instead we go through the Auth0Request.getUrl method to handle the abstraction. Internally, for NextRequest, it will return nextUrl.


const sanitizedPathname = removeTrailingSlash(pathname);
const method = req.method;
const method = auth0Req.getMethod();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We no longer use .method, instead we go through the Auth0Request.getMethod() method to handle the abstraction. Internally, for NextRequest, it will return method.


if (method === "GET" && sanitizedPathname === this.routes.login) {
return this.handleLogin(req);
return this.#unwrapHandler(() => this.handleLogin(auth0Req, auth0Res));
Copy link
Member Author

@frederikprijck frederikprijck Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleX() no longer returns a NextResponse. Instead, it returns an Auth0NextResponse. Instead of returning whatever handleX() returns, we have to unwrap the res property, containing the NextResponse instance, and return that one.

Because of the reason mentioned here, every handleX() method now also accepts an Auth0Response instance to do response manipulations directly on the provided Auth0Response instance.

const res = NextResponse.next();
const session = await this.sessionStore.get(req.cookies);

const session = await this.sessionStore.get(auth0Req.getCookies());
Copy link
Member Author

@frederikprijck frederikprijck Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as nextUrl, and method, we should not try and read the cookies directly from NextRequest. Instead, we read them through Auth0Request, handling the NextRequest specific cookie handling.

Comment on lines +454 to +461
await this.sessionStore.set(
auth0Req.getCookies(),
auth0Res.getCookies(),
{
...session
}
);
auth0Res.addCacheControlHeadersForSession();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As manipulating headers differs between NextResponse and NextApiResponse, we should call this from Auth0Response instead.

const session = await this.sessionStore.get(req.cookies);

const session = await this.sessionStore.get(auth0Req.getCookies());
const auth0Res = new Auth0NextResponse(NextResponse.next());
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, we just want to continue the next middleware, so we need to call NextResponse.next(), but wrapped in a new Auth0NextResponse.

Important: this is still bound to NextResponse. But this is fine as this is middleware only. We will need solve this in the. follow up PR to add support for using the SDK without middleware.

}

return res;
return auth0Res.res;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to return the underlyingNextResponse.

Comment on lines 472 to 475
async #unwrapHandler(handler: () => Promise<Auth0Response>): Promise<NextResponse> {
const auth0Response = await handler();
return auth0Response.res;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, this only returns Promise<NextResponse>. This is fine for now, but in the follow-up PR that adds support for mounting the auth routes without middelware, we will change unwrapHandler to unwrap based on the context it's used in.

}

async startInteractiveLogin(
auth0Res: Auth0Response,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the internal method, not exposed to our users. This is used by server/client.ts, the instance exposed to the user.

if (this.logoutStrategy === "v2") {
// Always use v2 logout endpoint
logoutResponse = createV2LogoutResponse();
createV2LogoutResponse(auth0Res);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of returning a new instance, we have to call methods on the Auth0Response.

Comment on lines +727 to +729
const errorRes = await this.onCallback(new InvalidStateError(), {}, null);
auth0Res.setResponse(errorRes);
return auth0Res;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a weird thing, but onCallback is a property that gets set to the user-provided options.onCallback, or used the defaultOnCallback. It's configured to return a NextResponse.

To make this as generic as possible, the Auth0Response contains a setResponse() that allows you to just set the underlying response.

);

await this.transactionStore.delete(res.cookies, state);
auth0Res.setResponse(res);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


await this.sessionStore.set(req.cookies, res.cookies, session, true);
addCacheControlHeadersForSession(res);
auth0Res.setResponse(res);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reqCookies = mocks.reqCookies;
resCookies = mocks.resCookies;
auth0ReqCookies = mocks.auth0ReqCookies;
auth0ResCookies = mocks.auth0ResCookies;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need both the original cookies, and the Auth0Cookies, because we need to pass Auth0Cookies instances down to setChunkedCookie, but we want to still expect the set method to have been called on the original cookie instance.

): Promise<NextResponse> {
return this.authClient.startInteractiveLogin(options);
const auth0Res = await this.authClient.startInteractiveLogin(
new Auth0NextResponse(new NextResponse()),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startInteractiveLogin needs to set a cookie on the response. Before this PR, it would internally create and return NextResponse instances. However, we are changing this with this PR by providing the Auth0NextResponse object, wrapping a placeholder NextResponse, and have the startInteractive call corresponding method on the Auth0NextResponse instance (e.g. redirect(), as well as getCookies() to retrieve the cookies for the response, allowing the SDK to add a cookie as needed.

This is mostly changed with the knowledge that for Pages Router, Next.js will provide the NextApiResponse initially together with the NextApiRequest. But for the App Router, we only receive a NextRequest, and we should instantiate and return NextResponse ourselves. To unify this, we now always expect an Auth0Response implementation.

@frederikprijck frederikprijck marked this pull request as draft January 13, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants