Clerk frontend integration: one image, runtime-switched identity

- src/lib/auth/bearer.ts is the single identity dispatch for both
  server-side token consumers (RSC apiFetch and the /api proxy route):
  local -> httpOnly tc_session cookie; clerk -> Clerk getToken() session
  JWT. The Clerk SDK is imported lazily, so the air-gapped/local path
  never loads it
- Runtime env (AUTH_MODE / CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY),
  deliberately NOT build-time NEXT_PUBLIC_*: the same standalone image
  serves both deployment targets
- Conditional <ClerkProvider> in the root layout (publishableKey passed
  at render from runtime env); /login renders Clerk's <SignIn /> in
  clerk mode and the local form otherwise; proxy.ts middleware delegates
  to clerkMiddleware() only when active
- Helm: frontend deployment injects the Clerk keys from a Secret when
  auth.mode=clerk
- mode.ts unit-tested (default local, exact-match clerk, loud failure
  without the publishable key); the local path stays proven by all 29
  journeys; the Clerk branch is thin delegation to the SDK, exercised in
  deployment smoke per docs/clerk.md

157 Rust + 68 frontend tests + 29 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 10:36:51 -05:00
co-authored by Claude Fable 5
parent cbc8d35a2e
commit ceca21ca79
14 changed files with 302 additions and 22 deletions
@@ -25,6 +25,20 @@ spec:
env: env:
- name: API_ORIGIN - name: API_ORIGIN
value: http://teamclaw-server:8080 value: http://teamclaw-server:8080
{{- if eq .Values.auth.mode "clerk" }}
- name: AUTH_MODE
value: clerk
- name: CLERK_PUBLISHABLE_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.auth.clerkSecretName }}
key: publishable-key
- name: CLERK_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.auth.clerkSecretName }}
key: secret-key
{{- end }}
resources: {{- toYaml .Values.frontend.resources | nindent 12 }} resources: {{- toYaml .Values.frontend.resources | nindent 12 }}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
+2
View File
@@ -53,6 +53,8 @@ auth:
mode: oidc mode: oidc
issuerUrl: "" issuerUrl: ""
clientId: teamclaw clientId: teamclaw
# clerk mode: Secret with keys publishable-key + secret-key.
clerkSecretName: teamclaw-clerk
oauth: oauth:
issuerUrl: "" issuerUrl: ""
+15 -9
View File
@@ -43,19 +43,25 @@ keyed by the stable `sub` claim (`users.auth_subject`); an existing
local-auth user with the same email is linked rather than duplicated, and local-auth user with the same email is linked rather than duplicated, and
the role tracks the Clerk org role on every request. the role tracks the Clerk org role on every request.
## 3. Frontend wiring ## 3. Frontend wiring (built in)
Install `@clerk/nextjs`, wrap the app in `<ClerkProvider>`, and send the The frontend ships with the integration. Set three runtime env vars on
session token as the bearer on API calls: the frontend container — the SAME image serves local and Clerk
deployments, nothing is baked at build time:
```ts ```bash
const { getToken } = useAuth(); AUTH_MODE=clerk
const token = await getToken(); CLERK_PUBLISHABLE_KEY=pk_live_…
fetch("/api/user/me", { headers: { Authorization: `Bearer ${token}` } }); CLERK_SECRET_KEY=sk_live_…
``` ```
Clerk session tokens live ~60 seconds; `getToken()` transparently What flips on: the root layout wraps the tree in `<ClerkProvider>`
refreshes, so fetch it per request rather than storing it. (lazily imported — local mode never loads the SDK), `/login` renders
Clerk's `<SignIn />`, the middleware runs `clerkMiddleware()`, and every
server-side API call resolves its bearer through Clerk's `getToken()`
instead of the local session cookie (`src/lib/auth/bearer.ts` is the
single dispatch point). Clerk session JWTs live ~60s and refresh
transparently; the backend verifies each one against the instance JWKS.
## What is verified in CI ## What is verified in CI
+123 -2
View File
@@ -8,6 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"geist": "^1.7.2", "geist": "^1.7.2",
"next": "16.2.9", "next": "16.2.9",
@@ -328,6 +329,85 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@clerk/backend": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.6.0.tgz",
"integrity": "sha512-/b0Ys437C21CyXhgHu4KcFHEQ3+CrBXlB6omQC5TPo9MfrhkMHRtg3LtYsSKp86mUM3IZnrwcll3ioo1kWobOA==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^4.16.0",
"standardwebhooks": "^1.0.0",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.9.0"
}
},
"node_modules/@clerk/nextjs": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.5.0.tgz",
"integrity": "sha512-6F9w42XS2NMnZdPjEowDYhUR1NaVdukAtvP+Vi7Frcp82dRsFp/16YxuBlXK0Btotiy3b6cz4A7VDoIF/5QtFA==",
"license": "MIT",
"dependencies": {
"@clerk/backend": "^3.6.0",
"@clerk/react": "^6.8.0",
"@clerk/shared": "^4.16.0",
"server-only": "0.0.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.9.0"
},
"peerDependencies": {
"next": "^15.2.8 || ^15.3.8 || ^15.4.10 || ^15.5.9 || ^15.6.0-0 || ^16.0.10 || ^16.1.0-0",
"react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
"react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
}
},
"node_modules/@clerk/react": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.8.0.tgz",
"integrity": "sha512-qbTvOtIDuPuDOrbil5y96C8/yvagAE/UtrcwjEpO5rRVuwpLrklwiz7Mj2AxqLCO6v2KpxXEHpquzfuFr/BqUA==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^4.16.0",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.9.0"
},
"peerDependencies": {
"react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
"react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
}
},
"node_modules/@clerk/shared": {
"version": "4.16.0",
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.16.0.tgz",
"integrity": "sha512-McccBHpv457pMbBMKKSLHuxqCSFe4YFAta1IngBX/uomdtogCDF++K8+dxZWxdYmWd+IrOVydIlLf3JzvEmLJA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "^5.100.6",
"dequal": "2.0.3",
"glob-to-regexp": "0.4.1",
"js-cookie": "3.0.7"
},
"engines": {
"node": ">=20.9.0"
},
"peerDependencies": {
"react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
"react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -1606,6 +1686,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -3588,9 +3674,7 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -4366,6 +4450,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -4624,6 +4714,12 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause"
},
"node_modules/globals": { "node_modules/globals": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -5323,6 +5419,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-cookie": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
"integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6768,6 +6873,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/server-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -7004,6 +7115,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": { "node_modules/std-env": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"geist": "^1.7.2", "geist": "^1.7.2",
"next": "16.2.9", "next": "16.2.9",
+3 -2
View File
@@ -4,13 +4,14 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { apiOrigin, TOKEN_COOKIE } from "@/lib/api/http"; import { apiOrigin } from "@/lib/api/http";
import { resolveBearer } from "@/lib/auth/bearer";
async function proxy( async function proxy(
request: NextRequest, request: NextRequest,
context: { params: Promise<{ path: string[] }> }, context: { params: Promise<{ path: string[] }> },
) { ) {
const token = request.cookies.get(TOKEN_COOKIE)?.value; const token = await resolveBearer();
if (!token) { if (!token) {
return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
} }
+18 -1
View File
@@ -3,6 +3,21 @@ import localFont from "next/font/local";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/next/app";
import { RegisterServiceWorker } from "@/components/shell/RegisterServiceWorker"; import { RegisterServiceWorker } from "@/components/shell/RegisterServiceWorker";
import { authMode, clerkPublishableKey } from "@/lib/auth/mode";
/** Wraps the tree in Clerk's provider only when the deployment uses
* Clerk; the air-gapped/local build never loads the SDK. */
async function IdentityProvider({ children }: { children: React.ReactNode }) {
if (authMode() !== "clerk") {
return <>{children}</>;
}
const { ClerkProvider } = await import("@clerk/nextjs");
return (
<ClerkProvider publishableKey={clerkPublishableKey()}>
{children}
</ClerkProvider>
);
}
import "./globals.css"; import "./globals.css";
// Vendored variable fonts (src/fonts) — zero external requests, so the same // Vendored variable fonts (src/fonts) — zero external requests, so the same
@@ -30,7 +45,9 @@ export default function RootLayout({
return ( return (
<html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}> <html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}>
<body className="min-h-full antialiased"> <body className="min-h-full antialiased">
<NuqsAdapter>{children}</NuqsAdapter> <IdentityProvider>
<NuqsAdapter>{children}</NuqsAdapter>
</IdentityProvider>
<RegisterServiceWorker /> <RegisterServiceWorker />
</body> </body>
</html> </html>
+10 -1
View File
@@ -1,6 +1,15 @@
import { LoginForm } from "@/components/auth/LoginForm"; import { LoginForm } from "@/components/auth/LoginForm";
import { authMode } from "@/lib/auth/mode";
export default function LoginPage() { export default async function LoginPage() {
if (authMode() === "clerk") {
const { SignIn } = await import("@clerk/nextjs");
return (
<main className="flex min-h-dvh items-center justify-center">
<SignIn routing="hash" />
</main>
);
}
return ( return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]"> <main className="flex min-h-dvh flex-col items-center justify-center gap-6 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
<div className="text-center"> <div className="text-center">
+8 -7
View File
@@ -1,10 +1,12 @@
// Server-side fetch against the Rust API. The session token lives in an // Server-side fetch against the Rust API. The caller's identity comes
// httpOnly cookie set by /auth/session and is forwarded as a bearer header. // from resolveBearer(): the httpOnly session cookie in local mode, the
// Clerk session JWT in clerk mode.
import { cookies } from "next/headers";
import type { z } from "zod"; import type { z } from "zod";
export const TOKEN_COOKIE = "tc_session"; import { resolveBearer } from "@/lib/auth/bearer";
export { TOKEN_COOKIE } from "@/lib/auth/bearer";
/** Origin of the Rust backend, reachable from the Next server process. */ /** Origin of the Rust backend, reachable from the Next server process. */
export function apiOrigin(): string { export function apiOrigin(): string {
@@ -25,10 +27,9 @@ export async function apiFetch<T>(
path: string, path: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<T> {
const store = await cookies(); const token = await resolveBearer();
const token = store.get(TOKEN_COOKIE)?.value;
if (!token) { if (!token) {
throw new ApiAuthError("no session cookie"); throw new ApiAuthError("no session");
} }
const res = await fetch(`${apiOrigin()}${path}`, { const res = await fetch(`${apiOrigin()}${path}`, {
...init, ...init,
+23
View File
@@ -0,0 +1,23 @@
// One place resolves "who is calling" into a bearer token for the Rust
// backend, in both server contexts (RSC apiFetch and the /api proxy):
// local -> the httpOnly tc_session cookie (opaque token)
// clerk -> the Clerk session JWT via the server SDK (verified by the
// backend against the instance JWKS)
// The Clerk SDK is imported lazily so the local/air-gapped path never
// loads it.
import { cookies } from "next/headers";
import { authMode } from "./mode";
export const TOKEN_COOKIE = "tc_session";
export async function resolveBearer(): Promise<string | null> {
if (authMode() === "clerk") {
const { auth } = await import("@clerk/nextjs/server");
const { getToken } = await auth();
return getToken();
}
const store = await cookies();
return store.get(TOKEN_COOKIE)?.value ?? null;
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "vitest";
import { authMode, clerkPublishableKey } from "./mode";
const ORIGINAL_MODE = process.env.AUTH_MODE;
const ORIGINAL_KEY = process.env.CLERK_PUBLISHABLE_KEY;
afterEach(() => {
if (ORIGINAL_MODE === undefined) delete process.env.AUTH_MODE;
else process.env.AUTH_MODE = ORIGINAL_MODE;
if (ORIGINAL_KEY === undefined) delete process.env.CLERK_PUBLISHABLE_KEY;
else process.env.CLERK_PUBLISHABLE_KEY = ORIGINAL_KEY;
});
describe("authMode", () => {
it("defaults to local (the air-gapped appliance mode)", () => {
delete process.env.AUTH_MODE;
expect(authMode()).toBe("local");
});
it("anything that is not exactly 'clerk' stays local", () => {
process.env.AUTH_MODE = "oidc";
expect(authMode()).toBe("local");
process.env.AUTH_MODE = "Clerk";
expect(authMode()).toBe("local");
});
it("clerk mode activates on the exact value", () => {
process.env.AUTH_MODE = "clerk";
expect(authMode()).toBe("clerk");
});
});
describe("clerkPublishableKey", () => {
it("returns the configured key", () => {
process.env.CLERK_PUBLISHABLE_KEY = "pk_test_abc";
expect(clerkPublishableKey()).toBe("pk_test_abc");
});
it("fails loudly when clerk mode lacks its key", () => {
delete process.env.CLERK_PUBLISHABLE_KEY;
expect(() => clerkPublishableKey()).toThrow(/CLERK_PUBLISHABLE_KEY/);
});
});
+18
View File
@@ -0,0 +1,18 @@
// Server-side auth mode (runtime env, NOT build-time NEXT_PUBLIC_*):
// the same standalone image serves the air-gapped appliance (local) and
// Clerk-backed cloud deployments — set AUTH_MODE per deployment.
export type AuthMode = "local" | "clerk";
export function authMode(): AuthMode {
return process.env.AUTH_MODE === "clerk" ? "clerk" : "local";
}
/** Clerk's publishable key, required whenever AUTH_MODE=clerk. */
export function clerkPublishableKey(): string {
const key = process.env.CLERK_PUBLISHABLE_KEY;
if (!key) {
throw new Error("AUTH_MODE=clerk requires CLERK_PUBLISHABLE_KEY");
}
return key;
}
+22
View File
@@ -0,0 +1,22 @@
// Next middleware (Next 16: proxy.ts). In clerk mode Clerk's middleware
// establishes the request auth context that resolveBearer() reads; in
// local mode requests pass straight through.
import { NextResponse, type NextRequest } from "next/server";
import type { NextFetchEvent } from "next/server";
export default async function proxy(
request: NextRequest,
event: NextFetchEvent,
) {
if (process.env.AUTH_MODE === "clerk") {
const { clerkMiddleware } = await import("@clerk/nextjs/server");
return clerkMiddleware()(request, event);
}
return NextResponse.next();
}
export const config = {
// Everything except static assets and prebuilt files.
matcher: ["/((?!_next|icons|fonts|.*\\.(?:png|svg|ico|woff2?|js|css|map)$).*)"],
};