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:
co-authored by
Claude Fable 5
parent
cbc8d35a2e
commit
ceca21ca79
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+123
-2
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<IdentityProvider>
|
||||||
<NuqsAdapter>{children}</NuqsAdapter>
|
<NuqsAdapter>{children}</NuqsAdapter>
|
||||||
|
</IdentityProvider>
|
||||||
<RegisterServiceWorker />
|
<RegisterServiceWorker />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)$).*)"],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user