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
139 changes: 139 additions & 0 deletions apps/web/app/auth/callback/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
cookieStore: new Map<string, string>(),
resolveWorkosIdentity: vi.fn(),
}));

vi.mock("next/headers", () => ({
cookies: () =>
Promise.resolve({
set: (name: string, value: string) => mocks.cookieStore.set(name, value),
}),
}));
vi.mock("@clockwork/db", () => ({
resolveWorkosIdentity: mocks.resolveWorkosIdentity,
}));
vi.mock("@/src/db/service", () => ({
getServiceDatabase: () => ({ kind: "test-database" }),
}));

vi.stubEnv("WORKOS_API_KEY", "sk_test_callback");
vi.stubEnv("WORKOS_CLIENT_ID", "client_test_callback");
vi.stubEnv(
"WORKOS_COOKIE_PASSWORD",
"callback-test-password-at-least-32-characters",
);
vi.stubEnv("APP_ORIGIN", "https://commerce.example.test");

// Exercise AuthKit's real state validation, session persistence and callback
// error handling. Only the provider's code exchange and commerce DB are faked.
const { getSignInUrl, getWorkOS } = await import("@workos-inc/authkit-nextjs");
const { GET } = await import("./route");

beforeEach(() => {
mocks.cookieStore.clear();
mocks.resolveWorkosIdentity.mockReset();
});

afterEach(() => {
vi.restoreAllMocks();
});

async function callbackRequest(returnTo?: string) {
const authorizationUrl = new URL(
await getSignInUrl({
redirectUri: "https://commerce.example.test/auth/callback",
...(returnTo ? { returnTo } : {}),
}),
);
const callbackUrl = new URL("http://container:3000/auth/callback");
callbackUrl.searchParams.set("code", "test-authorization-code");
const state = authorizationUrl.searchParams.get("state");
if (!state) throw new Error("AuthKit did not generate OAuth state");
callbackUrl.searchParams.set("state", state);
const request = new NextRequest(callbackUrl);
for (const [name, value] of mocks.cookieStore)
request.cookies.set(name, value);
return request;
}

function successfulCodeExchange(organizationId: string | undefined) {
return vi
.spyOn(getWorkOS().userManagement, "authenticateWithCode")
.mockResolvedValue({
accessToken: "test-access-token",
refreshToken: "test-refresh-token",
authenticationMethod: "MagicAuth",
...(organizationId ? { organizationId } : {}),
user: {
object: "user",
id: "user_callback",
email: "person@example.test",
emailVerified: true,
firstName: null,
lastName: null,
name: null,
locale: null,
profilePictureUrl: null,
lastSignInAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
externalId: null,
metadata: {},
},
});
}

it.each(["org_callback", undefined])(
"redirects a verified login without consulting commerce membership (organization: %s)",
async (organizationId) => {
successfulCodeExchange(organizationId);
mocks.resolveWorkosIdentity.mockRejectedValue(
new Error("Commerce database unavailable"),
);
const response = await GET(await callbackRequest());

expect(mocks.cookieStore.get("wos-session")).toBeTruthy();
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"https://commerce.example.test/",
);
expect(response.headers.get("cache-control")).toContain("no-store");
expect(response.headers.get("set-cookie")).toContain("wos-auth-verifier-");
expect(mocks.resolveWorkosIdentity).not.toHaveBeenCalled();
},
);

it("preserves the requested destination on the configured public origin", async () => {
successfulCodeExchange("org_callback");
const response = await GET(await callbackRequest("/internal?tab=activity"));
expect(response.headers.get("location")).toBe(
"https://commerce.example.test/internal?tab=activity",
);
});

it("does not create a session or redirect when the provider rejects the code", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(
getWorkOS().userManagement,
"authenticateWithCode",
).mockRejectedValue(new Error("Invalid code"));
const response = await GET(await callbackRequest());
expect(response.status).toBeGreaterThanOrEqual(400);
expect(response.headers.get("location")).toBeNull();
expect(mocks.cookieStore.has("wos-session")).toBe(false);
});

it("rejects mismatched OAuth state before exchanging the code", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const exchange = successfulCodeExchange("org_callback");
const request = await callbackRequest();
for (const cookie of request.cookies.getAll())
request.cookies.set(cookie.name, "wrong-state");
const response = await GET(request);
expect(response.status).toBeGreaterThanOrEqual(400);
expect(mocks.cookieStore.has("wos-session")).toBe(false);
expect(exchange).not.toHaveBeenCalled();
});
16 changes: 4 additions & 12 deletions apps/web/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { handleAuth } from "@workos-inc/authkit-nextjs";
import { NextResponse } from "next/server";

import { resolveWorkosIdentity } from "@clockwork/db";

import { getServiceDatabase } from "@/src/db/service";

const configured = Boolean(
process.env.WORKOS_API_KEY &&
process.env.WORKOS_CLIENT_ID &&
Expand All @@ -14,13 +10,9 @@ export const GET = configured
? handleAuth({
returnPathname: "/",
...(process.env.APP_ORIGIN ? { baseURL: process.env.APP_ORIGIN } : {}),
onSuccess: async ({ organizationId, user }) => {
if (!organizationId) return;
await resolveWorkosIdentity(getServiceDatabase(), {
workosUserId: user.id,
workosOrganizationId: organizationId,
requestId: `auth-callback:${crypto.randomUUID()}`,
});
},
// AuthKit saves the session before onSuccess; a lookup failure there
// turns a successful login into an error response with a live cookie.
// Commerce membership and permissions are checked by getCommerceSession
// on the destination page and every protected operation.
})
: (request: Request) => NextResponse.redirect(new URL("/", request.url));
20 changes: 20 additions & 0 deletions apps/web/src/auth/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,26 @@ describe("WorkOS commerce session mapping", () => {
vi.unstubAllEnvs();
});

it.each([
"WorkOS identity is not linked to exactly one commerce membership",
"Commerce database unavailable",
])(
"denies commerce access after a verified login when %s",
async (message) => {
const failure = new Error(message);
authMocks.resolveWorkosIdentity.mockRejectedValue(failure);

await expect(getCommerceSession()).rejects.toBe(failure);
expect(authMocks.resolveWorkosIdentity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
workosUserId: fixture.workosUserId,
workosOrganizationId: fixture.workosOrganizationId,
}),
);
},
);

it("accepts an actual session-bound MFA receipt when WorkOS omits assurance claims", async () => {
authMocks.getTokenClaims.mockResolvedValue({});
authMocks.findMfaReceipt.mockResolvedValue(Date.now());
Expand Down
2 changes: 2 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export default defineConfig({
},
},
test: {
// AuthKit's extensionless Next imports need the same bundling as the app.
server: { deps: { inline: ["@workos-inc/authkit-nextjs"] } },
environment: "jsdom",
include: ["{src,app}/**/*.test.{ts,tsx}"],
exclude: [
Expand Down
12 changes: 7 additions & 5 deletions docs/operations/dependency-chain-upgrades.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ the disposable OpenAPI dry-run are release assertions.

## Trigger/OpenTelemetry chain

The repository’s Trigger SDK/core remains 4.5.9. Only dependencies below
`@trigger.dev/core@4.5.9` are aligned: stable OpenTelemetry packages use 2.10.0
and the matching experimental log/exporter/instrumentation packages use 0.221.0.
The lockfile contains neither `@opentelemetry/core@2.7.1` nor the prior
experimental 0.218.0 family.
Trigger SDK/core is 4.6.4, which supports Zod 4 and includes the 4.6.x Zod
import fix. Its Zod dependency is pinned to 4.6.6 to remove the vulnerable Zod 3
dependency without forcing Zod 4 into an incompatible consumer. The application
catalog also uses Zod 4.6.6. Dependencies below `@trigger.dev/core@4.6.4` remain
aligned: stable OpenTelemetry packages use 2.10.0 and the matching experimental
log/exporter/instrumentation packages use 0.221.0. The lockfile contains neither
`@opentelemetry/core@2.7.1` nor the prior experimental 0.218.0 family.

Each override names both the Trigger parent and the individual OpenTelemetry
package. There is no blanket `@opentelemetry/*` override, so an incompatible
Expand Down
29 changes: 16 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"@netlify/plugin-nextjs": "5.15.13",
"@secretlint/secretlint-rule-pattern": "13.0.4",
"@secretlint/secretlint-rule-preset-recommend": "13.0.4",
"@trigger.dev/sdk": "4.5.9",
"@trigger.dev/sdk": "4.6.4",
"@types/node": "24.13.3",
"dependency-cruiser": "18.1.0",
"esbuild": "0.28.1",
Expand Down Expand Up @@ -96,17 +96,17 @@
"vite@>=8 <8.2.4": "8.2.4",
"@esbuild-kit/core-utils@3.3.2>esbuild": "0.25.12",
"netlify-cli@27.1.1>@opentelemetry/api": "1.8.0",
"@trigger.dev/core@4.5.9>@opentelemetry/api-logs": "0.221.0",
"@trigger.dev/core@4.5.9>@opentelemetry/core": "2.10.0",
"@trigger.dev/core@4.5.9>@opentelemetry/exporter-logs-otlp-http": "0.221.0",
"@trigger.dev/core@4.5.9>@opentelemetry/exporter-metrics-otlp-http": "0.221.0",
"@trigger.dev/core@4.5.9>@opentelemetry/exporter-trace-otlp-http": "0.221.0",
"@trigger.dev/core@4.5.9>@opentelemetry/instrumentation": "0.221.0",
"@trigger.dev/core@4.5.9>@opentelemetry/resources": "2.10.0",
"@trigger.dev/core@4.5.9>@opentelemetry/sdk-logs": "0.221.0",
"@trigger.dev/core@4.5.9>@opentelemetry/sdk-metrics": "2.10.0",
"@trigger.dev/core@4.5.9>@opentelemetry/sdk-trace-base": "2.10.0",
"@trigger.dev/core@4.5.9>@opentelemetry/sdk-trace-node": "2.10.0",
"@trigger.dev/core@4.6.4>@opentelemetry/api-logs": "0.221.0",
"@trigger.dev/core@4.6.4>@opentelemetry/core": "2.10.0",
"@trigger.dev/core@4.6.4>@opentelemetry/exporter-logs-otlp-http": "0.221.0",
"@trigger.dev/core@4.6.4>@opentelemetry/exporter-metrics-otlp-http": "0.221.0",
"@trigger.dev/core@4.6.4>@opentelemetry/exporter-trace-otlp-http": "0.221.0",
"@trigger.dev/core@4.6.4>@opentelemetry/instrumentation": "0.221.0",
"@trigger.dev/core@4.6.4>@opentelemetry/resources": "2.10.0",
"@trigger.dev/core@4.6.4>@opentelemetry/sdk-logs": "0.221.0",
"@trigger.dev/core@4.6.4>@opentelemetry/sdk-metrics": "2.10.0",
"@trigger.dev/core@4.6.4>@opentelemetry/sdk-trace-base": "2.10.0",
"@trigger.dev/core@4.6.4>@opentelemetry/sdk-trace-node": "2.10.0",
"brace-expansion": "5.0.12",
"nanoid@<3.3.18": "3.3.18",
"postcss@<8.5.17": "8.5.25",
Expand All @@ -118,7 +118,10 @@
"sharp@<0.35.5": "0.35.5",
"image-size@<2.0.4": "2.0.4",
"svgo@>=4 <4.1.0": "4.1.0",
"undici@>=8 <8.10.2": "8.10.2",
"undici@>=8 <8.12.2": "8.12.2",
"body-parser@>=2 <2.4.0": "2.4.0",
"@trigger.dev/core@4.6.4>zod": "4.6.6",
"zod@>=4 <4.6.6": "4.6.6",
"engine.io@<6.6.11": "6.6.11",
"fast-uri@>=3 <3.1.8": "3.1.8",
"fast-uri@>=4 <4.1.5": "4.1.5",
Expand Down
Loading
Loading