From 08effda09e1c67bd8d1945cb19f3cadc83ff2fad Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:01:32 +0800 Subject: [PATCH 1/2] fix: refresh token error --- packages/authjs-rxlab/README.md | 37 ++++++++- packages/authjs-rxlab/src/index.test.ts | 56 ++++++++++++- packages/authjs-rxlab/src/index.ts | 106 ++++++++++++++++++++++-- 3 files changed, 188 insertions(+), 11 deletions(-) diff --git a/packages/authjs-rxlab/README.md b/packages/authjs-rxlab/README.md index 2926d6a..9ca57f5 100644 --- a/packages/authjs-rxlab/README.md +++ b/packages/authjs-rxlab/README.md @@ -16,7 +16,7 @@ bun add @rxtech-lab/authjs-rxlab next-auth@beta // lib/auth.ts import { createRxLabAuth } from "@rxtech-lab/authjs-rxlab"; -export const { handlers, signIn, signOut, auth } = createRxLabAuth({ +export const { handlers, signIn, signOut, auth, proxy } = createRxLabAuth({ issuer: process.env.AUTH_ISSUER!, clientId: process.env.AUTH_CLIENT_ID!, clientSecret: process.env.AUTH_CLIENT_SECRET!, @@ -31,6 +31,24 @@ import { handlers } from "@/lib/auth"; export const { GET, POST } = handlers; ``` +Add a root `src/proxy.ts` so Auth.js owns an outgoing response on application +requests and can persist rotated refresh tokens: + +```ts +// src/proxy.ts +export { proxy as default } from "@/lib/auth"; + +export const config = { + matcher: [ + "/((?!api/auth|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; +``` + +The matcher excludes Auth.js endpoints, Next.js static/image requests, and +common metadata or image assets. Keep the proxy enabled for every route that +may read the session. + Sign in with the provider ID `rxlab`: ```ts @@ -63,6 +81,18 @@ The RxLab OAuth client must allow your Auth.js callback URL and the scopes ## Refresh behavior +Exporting `proxy` is required for reliable refresh-token rotation. Auth.js +5.0.0-beta.31's no-argument `auth()` path for React Server Components reads the +session response body but does not copy its `Set-Cookie` header to a browser +response. An RSC call can therefore refresh successfully in that invocation +while the browser keeps the old JWT cookie. Once RxLab's grace window for the +old refresh token closes, a later refresh fails with `invalid_grant`. + +The package's `proxy` is pre-wrapped through Auth.js's request/response path, +which copies the refreshed session cookie to the outgoing response. Continue +using `auth()` in Server Components to read the session, but do not rely on RSC +`auth()` calls alone to persist token rotation. + - Uses an encrypted Auth.js JWT session lasting 30 days by default. - Stores OAuth expiry in `expiresAt`, separate from Auth.js's reserved `exp`. - Refreshes access tokens 60 seconds before expiry. @@ -70,8 +100,9 @@ The RxLab OAuth client must allow your Auth.js callback URL and the scopes does not return a replacement. - Exposes `RefreshTokenError` on `session.error` when re-authentication is required. -- Logs only token presence, expiry, event, and HTTP status. Token values are - never passed to the optional logger. +- Logs only token presence, expiry, event, HTTP status, and sanitized OAuth + `error`/`error_description` details. Known token and client-secret values are + redacted and all other response fields are discarded. All defaults can be adjusted through `RxLabAuthOptions`. Advanced applications can call `createRxLabAuthConfig(options)` and inspect or extend the resulting diff --git a/packages/authjs-rxlab/src/index.test.ts b/packages/authjs-rxlab/src/index.test.ts index 0865522..b4f9356 100644 --- a/packages/authjs-rxlab/src/index.test.ts +++ b/packages/authjs-rxlab/src/index.test.ts @@ -1,4 +1,4 @@ -import type { Account, Profile, Session } from "next-auth"; +import NextAuth, { type Account, type Profile, type Session } from "next-auth"; import type { JWT } from "next-auth/jwt"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -12,6 +12,7 @@ vi.mock("next-auth", () => ({ })); import { + createRxLabAuth, createRxLabAuthConfig, RX_LAB_PROVIDER_ID, RX_LAB_REFRESH_TOKEN_ERROR, @@ -37,6 +38,46 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe("createRxLabAuth", () => { + it("exposes a proxy that delegates through Auth.js's response-owning path", async () => { + const response = new Response(null, { + headers: { "Set-Cookie": "authjs.session-token=rotated" }, + }); + const proxyHandler = vi.fn().mockResolvedValue(response); + const auth = vi.fn((wrapper?: unknown) => + typeof wrapper === "function" ? proxyHandler : null, + ); + const handlers = { GET: vi.fn(), POST: vi.fn() }; + const signIn = vi.fn(); + const signOut = vi.fn(); + vi.mocked(NextAuth).mockReturnValueOnce({ + handlers, + auth, + signIn, + signOut, + unstable_update: vi.fn(), + } as never); + + const result = createRxLabAuth(baseOptions); + const request = new Request("https://app.example.com/admin") as Parameters< + typeof result.proxy + >[0]; + const event = {} as Parameters[1]; + + const proxyResponse = await result.proxy(request, event); + + expect(auth).toHaveBeenCalledOnce(); + expect(auth).toHaveBeenCalledWith(expect.any(Function)); + expect(proxyHandler).toHaveBeenCalledOnce(); + expect(proxyHandler).toHaveBeenCalledWith(request, event); + expect(proxyResponse).toBe(response); + expect((proxyResponse as Response).headers.get("set-cookie")).toBe( + "authjs.session-token=rotated", + ); + expect(result).toMatchObject({ handlers, auth, signIn, signOut }); + }); +}); + describe("createRxLabAuthConfig", () => { it("configures the RxLab OIDC provider and long-lived JWT session", () => { const config = createRxLabAuthConfig(baseOptions); @@ -180,6 +221,8 @@ describe("createRxLabAuthConfig", () => { new Response( JSON.stringify({ error: "invalid_grant", + error_description: + "Invalid\nrefresh token refresh-one for client-secret and access-one", leaked_value: "refresh-one", }), { status: 401, headers: { "Content-Type": "application/json" } }, @@ -190,7 +233,11 @@ describe("createRxLabAuthConfig", () => { fetch: fetchMock, logger, }).jwt({ - token: { refreshToken: "refresh-one", expiresAt: 1 }, + token: { + accessToken: "access-one", + refreshToken: "refresh-one", + expiresAt: 1, + }, trigger: "update", }); @@ -200,9 +247,14 @@ describe("createRxLabAuthConfig", () => { hasRefreshToken: true, expiresAt: 1, status: 401, + oauthError: "invalid_grant", + oauthErrorDescription: + "Invalid refresh token [redacted] for [redacted] and [redacted]", }); expect(JSON.stringify(logger.mock.calls)).not.toContain("refresh-one"); expect(JSON.stringify(logger.mock.calls)).not.toContain("client-secret"); + expect(JSON.stringify(logger.mock.calls)).not.toContain("access-one"); + expect(JSON.stringify(logger.mock.calls)).not.toContain("leaked_value"); }); it("projects the access token, identity, roles, and error onto the session", async () => { diff --git a/packages/authjs-rxlab/src/index.ts b/packages/authjs-rxlab/src/index.ts index f0ef4ae..4c39c61 100644 --- a/packages/authjs-rxlab/src/index.ts +++ b/packages/authjs-rxlab/src/index.ts @@ -2,6 +2,7 @@ import NextAuth, { type NextAuthConfig, type NextAuthResult, } from "next-auth"; +import type { NextMiddleware } from "next/server"; export const RX_LAB_PROVIDER_ID = "rxlab"; export const RX_LAB_REFRESH_TOKEN_ERROR = "RefreshTokenError"; @@ -18,6 +19,8 @@ export interface RxLabAuthLogEntry { hasRefreshToken: boolean; expiresAt: number | null; status?: number; + oauthError?: string; + oauthErrorDescription?: string; } export type RxLabAuthLogger = ( @@ -62,6 +65,22 @@ interface RxLabJWTFields { error?: string; } +interface RxLabRefreshError extends Error { + status: number; + oauthError?: string; + oauthErrorDescription?: string; +} + +export type RxLabAuthProxy = NextMiddleware; + +export interface RxLabAuthResult extends NextAuthResult { + /** + * Response-owning Auth.js handler that persists rotated refresh tokens. + * Export this as the default handler from the application's `proxy.ts`. + */ + proxy: RxLabAuthProxy; +} + declare module "next-auth" { interface Session { accessToken?: string; @@ -92,6 +111,31 @@ function isTokenResponse(value: unknown): value is RxLabTokenResponse { ); } +function sanitizeOAuthErrorField( + value: unknown, + sensitiveValues: readonly (string | undefined)[], +): string | undefined { + if (typeof value !== "string") return undefined; + + let sanitized = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim(); + for (const sensitiveValue of sensitiveValues) { + if (sensitiveValue) { + sanitized = sanitized.replaceAll(sensitiveValue, "[redacted]"); + } + } + + sanitized = sanitized.replace(/\s+/g, " ").slice(0, 256); + return sanitized || undefined; +} + +function tokenValuesFromResponse(body: unknown): (string | undefined)[] { + if (!body || typeof body !== "object") return []; + const response = body as Record; + return ["access_token", "refresh_token", "id_token"].map((key) => + typeof response[key] === "string" ? response[key] : undefined, + ); +} + function defaultLogger( enabled: boolean, ): RxLabAuthLogger | undefined { @@ -130,7 +174,10 @@ export function createRxLabAuthConfig( throw new TypeError("A fetch implementation is required"); } - async function refreshAccessToken(refreshToken: string) { + async function refreshAccessToken( + refreshToken: string, + accessToken: string | undefined, + ) { const response = await fetchImpl(`${issuer}/api/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -150,8 +197,28 @@ export function createRxLabAuthConfig( } if (!response.ok || !isTokenResponse(body)) { - const error = new Error(`RxLab token refresh failed (${response.status})`); - Object.assign(error, { status: response.status }); + const responseBody = + body && typeof body === "object" + ? (body as Record) + : undefined; + const sensitiveValues = [ + refreshToken, + accessToken, + clientSecret, + ...tokenValuesFromResponse(body), + ]; + const error = new Error( + `RxLab token refresh failed (${response.status})`, + ) as RxLabRefreshError; + error.status = response.status; + error.oauthError = sanitizeOAuthErrorField( + responseBody?.error, + sensitiveValues, + ); + error.oauthErrorDescription = sanitizeOAuthErrorField( + responseBody?.error_description, + sensitiveValues, + ); throw error; } @@ -233,7 +300,10 @@ export function createRxLabAuthConfig( } try { - const fresh = await refreshAccessToken(rxLabToken.refreshToken); + const fresh = await refreshAccessToken( + rxLabToken.refreshToken, + rxLabToken.accessToken, + ); logger?.("debug", { event: "refresh-succeeded", hasRefreshToken: true, @@ -253,11 +323,27 @@ export function createRxLabAuthConfig( typeof error.status === "number" ? error.status : undefined; + const oauthError = + error instanceof Error && + "oauthError" in error && + typeof error.oauthError === "string" + ? error.oauthError + : undefined; + const oauthErrorDescription = + error instanceof Error && + "oauthErrorDescription" in error && + typeof error.oauthErrorDescription === "string" + ? error.oauthErrorDescription + : undefined; logger?.("error", { event: "refresh-failed", hasRefreshToken: true, expiresAt: rxLabToken.expiresAt ?? null, ...(status === undefined ? {} : { status }), + ...(oauthError === undefined ? {} : { oauthError }), + ...(oauthErrorDescription === undefined + ? {} + : { oauthErrorDescription }), }); return { ...rest, error: RX_LAB_REFRESH_TOKEN_ERROR }; } @@ -285,6 +371,14 @@ export function createRxLabAuthConfig( } /** Create the complete Auth.js result used by route handlers and applications. */ -export function createRxLabAuth(options: RxLabAuthOptions): NextAuthResult { - return NextAuth(createRxLabAuthConfig(options)); +export function createRxLabAuth(options: RxLabAuthOptions): RxLabAuthResult { + const result = NextAuth(createRxLabAuthConfig(options)); + const continueRequest: NextMiddleware = () => undefined; + + return { + ...result, + // Wrapping Auth.js forces its request/response path, which forwards the + // refreshed session's Set-Cookie header to the browser. + proxy: result.auth(continueRequest), + }; } From 2d8a67ea1ccecd35276eea72a75535afced1c9be Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:11:11 +0800 Subject: [PATCH 2/2] ci: install Playwright browsers with the workspace-pinned CLI At the repo root there is no local playwright binary (@playwright/test is a dependency of examples/web), so `bunx playwright install` fetched the latest playwright from npm and downloaded chromium build 1234. The e2e run then used the lockfile-pinned @playwright/test 1.61.1, which looks for build 1228, and every test failed with "Executable doesn't exist". Run the install step from examples/web so bunx resolves the pinned CLI. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ae77cb..39db558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,8 +64,13 @@ jobs: run: bun install --frozen-lockfile - name: Build packages run: bun run build + # Run from examples/web so bunx resolves the workspace-pinned @playwright/test + # CLI. At the repo root there is no local playwright binary, so bunx would + # fetch the latest from npm and install a browser build the pinned runner + # does not look for. - name: Install Playwright browser run: bunx playwright install --with-deps chromium + working-directory: examples/web # Root test:e2e builds the app, then Playwright boots the Go API + Next app. - name: Run e2e run: bun run test:e2e