feat: enable stack auth config from backend

This commit is contained in:
Abhishek Kumar 2026-06-18 14:17:28 +05:30
parent 788ff94cec
commit f586aebe5b
13 changed files with 261 additions and 53 deletions

View file

@ -42,15 +42,6 @@ ENV NEXT_PUBLIC_CHATWOOT_URL="https://chat.dograh.com"
ENV NEXT_PUBLIC_CHATWOOT_TOKEN="3fkFx2mCEjNHjM9gaNc4A82X"
ENV BACKEND_URL="http://api:8000"
# Stack Auth (optional, for self-hosted social login). NEXT_PUBLIC_* values must
# be set at build time so Next.js inlines them into the browser bundle; setting
# them as runtime container env has no effect. Unset by default (empty strings),
# which leaves the official image on the built-in local auth flow.
ARG NEXT_PUBLIC_STACK_PROJECT_ID
ARG NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY
ENV NEXT_PUBLIC_STACK_PROJECT_ID=$NEXT_PUBLIC_STACK_PROJECT_ID
ENV NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=$NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY
# Build the application with standalone mode
# Increase Node.js heap size to prevent out-of-memory errors during build
ENV NODE_OPTIONS="--max-old-space-size=4096"

View file

@ -1,10 +1,17 @@
import { NextResponse } from 'next/server';
import { getAuthProvider } from '@/lib/auth/config';
import { getAuthProvider, getStackConfig } from '@/lib/auth/config';
import logger from '@/lib/logger';
export async function GET() {
const provider = await getAuthProvider();
// 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;
logger.debug(`Got provider ${provider} from getAuthProvider`)
return NextResponse.json({ provider });
return NextResponse.json({
provider,
stackProjectId: stackConfig?.projectId ?? null,
stackPublishableClientKey: stackConfig?.publishableClientKey ?? null,
});
}

View file

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getStackConfig } from "@/lib/auth/config";
/**
* Helper route that receives a refresh token via query parameters, stores it as
* the regular Stack cookie *for the current sub-domain only* and finally
@ -18,6 +20,13 @@ export async function GET(request: NextRequest) {
return new Response("Missing refresh_token", { status: 400 });
}
// The Stack session cookie is named `stack-refresh-<projectId>`. The project
// id comes from the backend at runtime, so no inlined NEXT_PUBLIC_* is needed.
const stackConfig = await getStackConfig();
if (!stackConfig) {
return new Response("Stack auth is not configured", { status: 400 });
}
// Prepare redirect if the supplied redirect path is an absolute URL we use
// it as-is, otherwise we resolve it relative to the current request.
const redirectUrl = redirectPath.startsWith("http")
@ -32,7 +41,7 @@ export async function GET(request: NextRequest) {
// Store the refresh token cookie without an explicit domain so that it is
// scoped to the current (sub-)domain. This avoids collisions between the
// admin (superadmin.*) and the regular app (app.*) domains.
response.cookies.set(`stack-refresh-${process.env.NEXT_PUBLIC_STACK_PROJECT_ID}` as string, refreshToken, {
response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, {
path: "/",
maxAge,
secure: true,

View file

@ -2,15 +2,29 @@ import "server-only";
import { getServerBackendUrl } from "@/lib/apiClient";
let cachedAuthProvider: string | null = null;
export interface StackConfig {
projectId: string;
publishableClientKey: string;
}
interface ResolvedAuthConfig {
authProvider: string;
stackConfig: StackConfig | null;
}
let cachedConfig: ResolvedAuthConfig | null = null;
/**
* Fetches the auth provider from the backend health endpoint and caches it.
* Falls back to 'local' on error.
* Fetches the auth configuration from the backend health endpoint and caches it.
*
* The backend reports the active auth provider and when it is `stack` the
* public Stack client config (project id + publishable client key). The UI uses
* these at runtime to initialize Stack Auth, so they no longer need to be baked
* into the browser bundle at build time. Falls back to local auth on error.
*/
export async function getAuthProvider(): Promise<string> {
if (cachedAuthProvider) {
return cachedAuthProvider;
async function resolveAuthConfig(): Promise<ResolvedAuthConfig> {
if (cachedConfig) {
return cachedConfig;
}
try {
@ -20,13 +34,39 @@ export async function getAuthProvider(): Promise<string> {
});
if (res.ok) {
const data = await res.json();
cachedAuthProvider = (data.auth_provider as string) || "local";
return cachedAuthProvider;
const authProvider = (data.auth_provider as string) || "local";
const stackConfig =
authProvider === "stack" &&
data.stack_project_id &&
data.stack_publishable_client_key
? {
projectId: data.stack_project_id as string,
publishableClientKey:
data.stack_publishable_client_key as string,
}
: null;
cachedConfig = { authProvider, stackConfig };
return cachedConfig;
}
} catch {
// Backend not reachable — fall back to local
}
cachedAuthProvider = "local";
return cachedAuthProvider;
cachedConfig = { authProvider: "local", stackConfig: null };
return cachedConfig;
}
/**
* Returns the active auth provider ('local' or 'stack'). Falls back to 'local'.
*/
export async function getAuthProvider(): Promise<string> {
return (await resolveAuthConfig()).authProvider;
}
/**
* Returns the public Stack client config when the active provider is `stack`,
* otherwise null. Server-only the browser receives these via /api/config/auth.
*/
export async function getStackConfig(): Promise<StackConfig | null> {
return (await resolveAuthConfig()).stackConfig;
}

View file

@ -42,31 +42,57 @@ const LoadingFallback = (
</div>
);
interface ResolvedAuthConfig {
provider: string;
// Public Stack client config, fetched from the backend at runtime. Null unless
// the provider is 'stack' and the backend supplied both values.
stack: { projectId: string; publishableClientKey: string } | null;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [authProvider, setAuthProvider] = useState<string | null>(null);
const [config, setConfig] = useState<ResolvedAuthConfig | null>(null);
useEffect(() => {
fetch('/api/config/auth')
.then((res) => res.json())
.then((data) => {
logger.debug(`Setting auth provider as ${data.provider}`)
setAuthProvider(data.provider || 'stack')
})
setConfig({
provider: data.provider || 'local',
stack:
data.stackProjectId && data.stackPublishableClientKey
? {
projectId: data.stackProjectId,
publishableClientKey: data.stackPublishableClientKey,
}
: null,
})
})
.catch((e) => {
logger.error(`Got error ${e} while setting auth provider`)
setAuthProvider('local')
setConfig({ provider: 'local', stack: null })
});
}, []);
if (!authProvider) {
if (!config) {
return LoadingFallback;
}
// For Stack provider, use the dedicated wrapper
if (authProvider === 'stack') {
if (config.provider === 'stack') {
if (!config.stack) {
logger.error(
'Auth provider is "stack" but the backend returned no Stack client config. ' +
'Ensure STACK_AUTH_PROJECT_ID and STACK_PUBLISHABLE_CLIENT_KEY are set on the API service.'
);
return LoadingFallback;
}
return (
<Suspense fallback={LoadingFallback}>
<StackProviderWrapper>
<StackProviderWrapper
projectId={config.stack.projectId}
publishableClientKey={config.stack.publishableClientKey}
>
{children}
</StackProviderWrapper>
</Suspense>

View file

@ -9,10 +9,18 @@ import { AuthContext } from './AuthProvider';
// Create a singleton StackClientApp instance to prevent multiple initializations
let stackClientAppInstance: StackClientApp<true, string> | null = null;
function getStackClientApp(): StackClientApp<true, string> {
function getStackClientApp(
projectId: string,
publishableClientKey: string,
): StackClientApp<true, string> {
if (!stackClientAppInstance) {
// projectId / publishableClientKey are passed explicitly (fetched from the
// backend at runtime) instead of being read from inlined NEXT_PUBLIC_* env,
// so the prebuilt image works without build-time configuration.
stackClientAppInstance = new StackClientApp({
tokenStore: "nextjs-cookie",
projectId,
publishableClientKey,
urls: {
afterSignIn: "/after-sign-in"
}
@ -23,6 +31,8 @@ function getStackClientApp(): StackClientApp<true, string> {
interface StackProviderWrapperProps {
children: React.ReactNode;
projectId: string;
publishableClientKey: string;
}
// Simple context provider that uses Stack's useUser directly
@ -114,8 +124,8 @@ const translationOverrides = {
"Sign up with {provider}": "Sign up with {provider} Business",
};
export function StackProviderWrapper({ children }: StackProviderWrapperProps) {
const stackClientApp = getStackClientApp();
export function StackProviderWrapper({ children, projectId, publishableClientKey }: StackProviderWrapperProps) {
const stackClientApp = getStackClientApp(projectId, publishableClientKey);
return (
<StackProvider app={stackClientApp} translationOverrides={translationOverrides}>

View file

@ -5,7 +5,7 @@ import { cookies } from 'next/headers';
import logger from '@/lib/logger';
import { getAuthProvider } from './config';
import { getAuthProvider, getStackConfig } from './config';
import type { LocalUser } from './types';
// Server-side auth utilities for SSR pages
@ -21,10 +21,23 @@ export async function getStackServerApp(): Promise<StackServerApp<boolean, strin
// Only import if using Stack provider
const authProvider = await getAuthProvider();
if (authProvider === 'stack') {
const stackConfig = await getStackConfig();
if (!stackConfig) {
logger.error(
'Auth provider is "stack" but Stack client config is unavailable from the backend ' +
'(STACK_AUTH_PROJECT_ID / STACK_PUBLISHABLE_CLIENT_KEY).'
);
return null;
}
const stackModule = await import('@stackframe/stack');
const { StackServerApp } = stackModule;
// projectId / publishableClientKey come from the backend at runtime. The
// secret server key stays a server-only runtime env var
// (STACK_SECRET_SERVER_KEY), read by the SDK directly.
stackServerApp = new StackServerApp({
tokenStore: "nextjs-cookie",
projectId: stackConfig.projectId,
publishableClientKey: stackConfig.publishableClientKey,
urls: {
afterSignIn: "/after-sign-in"
}

View file

@ -105,25 +105,6 @@ export async function getRedirectUrl(token: string, permissions: { id: string }[
}
/**
* --------------------------------------------------------------------------
* Cookie helpers
* --------------------------------------------------------------------------
*/
export function setStackRefreshCookie(refreshToken: string) {
const expiryDate = new Date();
expiryDate.setFullYear(expiryDate.getFullYear() + 1);
const isDograhDomain = window.location.hostname.endsWith('.dograh.com');
const cookieDomainPart = isDograhDomain ? '; domain=.dograh.com' : '';
document.cookie =
`stack-refresh-${process.env.NEXT_PUBLIC_STACK_PROJECT_ID}=${refreshToken}; ` +
`expires=${expiryDate.toUTCString()}; path=/` +
`${cookieDomainPart}; secure; samesite=lax`;
}
/**
* Centralised impersonation logic to avoid code duplication between pages.
*