Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 34 additions & 3 deletions packages/authjs-rxlab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand All @@ -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
Expand Down Expand Up @@ -63,15 +81,28 @@ 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.
- Persists a rotated refresh token, or keeps the previous token when the server
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
Expand Down
56 changes: 54 additions & 2 deletions packages/authjs-rxlab/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -12,6 +12,7 @@ vi.mock("next-auth", () => ({
}));

import {
createRxLabAuth,
createRxLabAuthConfig,
RX_LAB_PROVIDER_ID,
RX_LAB_REFRESH_TOKEN_ERROR,
Expand All @@ -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<typeof result.proxy>[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);
Expand Down Expand Up @@ -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" } },
Expand All @@ -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",
});

Expand All @@ -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 () => {
Expand Down
106 changes: 100 additions & 6 deletions packages/authjs-rxlab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -18,6 +19,8 @@ export interface RxLabAuthLogEntry {
hasRefreshToken: boolean;
expiresAt: number | null;
status?: number;
oauthError?: string;
oauthErrorDescription?: string;
}

export type RxLabAuthLogger = (
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
return ["access_token", "refresh_token", "id_token"].map((key) =>
typeof response[key] === "string" ? response[key] : undefined,
);
}

function defaultLogger(
enabled: boolean,
): RxLabAuthLogger | undefined {
Expand Down Expand Up @@ -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" },
Expand All @@ -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<string, unknown>)
: 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;
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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),
};
}