feat(auth): gate OSS signup behind ENABLE_SIGNUP flag (#514)

* feat(auth): gate OSS signup behind ENABLE_SIGNUP flag

## Problem

The `POST /api/v1/auth/signup` endpoint is unconditionally exposed on
every OSS install. Operators running an invite-only deployment (private
customer instances, staging environments, internal-only tenants) have
no way to disable public account creation without patching the codebase.
The UI also shows the "Sign up" link on `/auth/login` regardless of
whether signup is available, so a locked-down deployment leaves broken
navigation on the login page.

## Fix

Introduce a single `ENABLE_SIGNUP` env var (default `true` — no behavior
change for existing installs) that controls signup end-to-end:

- **Backend** — `api/constants.ENABLE_SIGNUP` is read at module load.
  The signup handler returns 403 when it's false. Also exposed on
  `GET /api/v1/health` as `signup_enabled: bool` so the UI can mirror
  the operator's choice at runtime instead of at bundle-build time.

- **UI** — `getSignupEnabled()` in `lib/auth/config.ts` proxies the
  health field, `/api/config/auth` surfaces it to the browser, the
  login page conditionally renders the "Sign up" link via a one-shot
  `fetch("/api/config/auth")` in `useEffect`, and the middleware
  redirects `/auth/signup` → `/auth/login` when disabled (fires before
  Next.js can serve the statically-prerendered signup page).

- **Helm** — `config.enableSignup` (default `true`) is rendered into
  the ConfigMap as `ENABLE_SIGNUP` so operators can flip it via
  `--set config.enableSignup=false` at install/upgrade time.

Fallbacks default to `signupEnabled: true` in every layer so a fresh
install "just works" and matches the backend default.

* address review: rollout on ConfigMap change, cache TTL, no signup-link flash

Four review points on #514:

**P1 — ConfigMap Change Skips Rollout** (`configmap.yaml`). `helm upgrade
--set config.enableSignup=false` updated the ConfigMap but did NOT roll
the api pods, so running processes kept the ENABLE_SIGNUP env from
startup and continued serving the old signup behavior — including
divergence between replicas mid-upgrade.

Fix: add the standard `checksum/config` pod-template annotation on the
four backend Deployments that `envFrom` the ConfigMap (`web`,
`arq-worker`, `ari-manager`, `campaign-orchestrator`). Verified with
`helm template`: all four Deployments share the same checksum on any
given render, and flipping `config.enableSignup` changes the checksum
uniformly so kubectl sees a pod-template diff and rolls all four.

**P1 — Signup Flag Stays Cached (server)** (`ui/src/lib/auth/config.ts`).
Module-scoped cache had no TTL. `revalidate: 300` was passed on the
underlying `fetch()` but the in-memory short-circuit above ran first, so
the value never refreshed until the UI pod restarted.

Fix: add `AUTH_CONFIG_TTL_MS = 5 * 60 * 1000` (matching the fetch
revalidate hint) so the module cache and the Next fetch cache stay in
sync. Backend flag flips propagate within 5 minutes without a pod
restart.

**P1 — Middleware Redirect Uses Stale State** (`ui/src/middleware.ts`).
Same shape as above — a separate module cache with no expiry could keep
redirecting `/auth/signup → /auth/login` after signup was re-enabled, or
keep serving the statically-prerendered signup page after lockdown.

Fix: same `SERVER_CONFIG_TTL_MS = 5 * 60 * 1000` TTL on the middleware
cache.

**P2 — Signup link flash on login page** (`ui/src/app/auth/login/page.tsx`).
Initial `signupEnabled` state was `null`, so `{signupEnabled && ...}`
hid the link on first paint and it popped in after the fetch resolved
— a CLS on every login-page load on stock installs where signup is
enabled.

Fix: initialise the state to `true` (matches the backend default). The
fetch still overrides to `false` when the operator has actually
disabled signup, so the lockdown UI behavior is unchanged; only the
happy-path flash is gone.

* simplify signup flag: drop TTL caches and middleware redirect

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* resolve signup flag server-side to avoid signup link flicker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: prabhat pankaj <prabhatiitbhu@gmail.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
prabhatlepton 2026-07-13 14:08:25 +05:30 committed by GitHub
parent 2c803bbea9
commit e7494e9c21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 184 additions and 101 deletions

View file

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { getAuthProvider, getStackConfig } from '@/lib/auth/config';
import { getAuthProvider, getSignupEnabled, getStackConfig } from '@/lib/auth/config';
import logger from '@/lib/logger';
export async function GET() {
@ -8,10 +8,12 @@ export async function GET() {
// When using Stack, hand the public client config to the browser so it can
// initialize the Stack SDK at runtime (no build-time NEXT_PUBLIC_* needed).
const stackConfig = provider === 'stack' ? await getStackConfig() : null;
const signupEnabled = await getSignupEnabled();
logger.debug(`Got provider ${provider} from getAuthProvider`)
return NextResponse.json({
provider,
stackProjectId: stackConfig?.projectId ?? null,
stackPublishableClientKey: stackConfig?.publishableClientKey ?? null,
signupEnabled,
});
}

View file

@ -0,0 +1,96 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { loginApiV1AuthLoginPost } from "@/client/sdk.gen";
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
import { AuthShell } from "@/components/auth/AuthShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function LoginForm({ signupEnabled }: { signupEnabled: boolean }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await loginApiV1AuthLoginPost({
body: { email, password },
});
if (res.error || !res.data) {
const detail = (res.error as { detail?: string })?.detail;
toast.error(detail || "Login failed");
return;
}
// Set httpOnly cookies via server route
await fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: res.data.token, user: res.data.user }),
});
window.location.href = "/after-sign-in";
} catch {
toast.error("An error occurred. Please try again.");
} finally {
setLoading(false);
}
};
return (
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
<div className="space-y-1.5 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">
Enter your email and password to continue
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
{signupEnabled && (
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
)}
</AuthShell>
);
}

View file

@ -1,94 +1,14 @@
"use client";
import { getSignupEnabled } from "@/lib/auth/config";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { LoginForm } from "./LoginForm";
import { loginApiV1AuthLoginPost } from "@/client/sdk.gen";
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
import { AuthShell } from "@/components/auth/AuthShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// Resolve the backend health check before rendering so the "Sign up" link is
// correct on first paint — no client-side fetch, no flicker on locked-down
// installs. force-dynamic keeps the page off the build-time prerender, which
// would bake in the flag's build-environment value.
export const dynamic = "force-dynamic";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await loginApiV1AuthLoginPost({
body: { email, password },
});
if (res.error || !res.data) {
const detail = (res.error as { detail?: string })?.detail;
toast.error(detail || "Login failed");
return;
}
// Set httpOnly cookies via server route
await fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: res.data.token, user: res.data.user }),
});
window.location.href = "/after-sign-in";
} catch {
toast.error("An error occurred. Please try again.");
} finally {
setLoading(false);
}
};
return (
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
<div className="space-y-1.5 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">
Enter your email and password to continue
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
</AuthShell>
);
export default async function LoginPage() {
const signupEnabled = await getSignupEnabled();
return <LoginForm signupEnabled={signupEnabled} />;
}

View file

@ -2884,6 +2884,10 @@ export type HealthResponse = {
* Force Turn Relay
*/
force_turn_relay: boolean;
/**
* Signup Enabled
*/
signup_enabled: boolean;
/**
* Stack Project Id
*/

View file

@ -10,6 +10,7 @@ export interface StackConfig {
interface ResolvedAuthConfig {
authProvider: string;
stackConfig: StackConfig | null;
signupEnabled: boolean;
}
let cachedConfig: ResolvedAuthConfig | null = null;
@ -45,7 +46,10 @@ async function resolveAuthConfig(): Promise<ResolvedAuthConfig> {
data.stack_publishable_client_key as string,
}
: null;
cachedConfig = { authProvider, stackConfig };
// Default to signup-enabled when the backend omits the field (older api
// versions before the flag existed) — matches the backend's own default.
const signupEnabled = data.signup_enabled !== false;
cachedConfig = { authProvider, stackConfig, signupEnabled };
return cachedConfig;
}
} catch {
@ -56,7 +60,7 @@ async function resolveAuthConfig(): Promise<ResolvedAuthConfig> {
// do NOT cache it: caching here would pin the entire UI to local auth until a
// container restart if the first resolution loses the startup race with the api
// service. Leaving it uncached means the next request retries and self-heals.
return { authProvider: "local", stackConfig: null };
return { authProvider: "local", stackConfig: null, signupEnabled: true };
}
/**
@ -73,3 +77,11 @@ export async function getAuthProvider(): Promise<string> {
export async function getStackConfig(): Promise<StackConfig | null> {
return (await resolveAuthConfig()).stackConfig;
}
/**
* Returns true when the backend allows signup (`ENABLE_SIGNUP`, default true).
* The login page uses this to hide the signup link on locked-down installs.
*/
export async function getSignupEnabled(): Promise<boolean> {
return (await resolveAuthConfig()).signupEnabled;
}