feat: auth token env bypass

This commit is contained in:
null 2026-05-19 22:28:34 -05:00
parent b2766ba063
commit 72b873845f
7 changed files with 60 additions and 51 deletions

View File

@ -29,3 +29,9 @@ LOCAL_AUTH_TOKEN=
# Use `auto` to target the same host currently serving Pipeline on port 8001. # Use `auto` to target the same host currently serving Pipeline on port 8001.
# Example (explicit override): NEXT_PUBLIC_API_URL=https://mc.example.com # Example (explicit override): NEXT_PUBLIC_API_URL=https://mc.example.com
NEXT_PUBLIC_API_URL=auto NEXT_PUBLIC_API_URL=auto
NEXT_PUBLIC_AUTH_MODE=local
# Local auth token (used when NEXT_PUBLIC_AUTH_MODE=local).
# When set: the app loads directly without showing the login screen.
# When unset: users are prompted to enter their token on first load.
# Must be at least 50 characters and match LOCAL_AUTH_TOKEN above.
NEXT_PUBLIC_LOCAL_AUTH_TOKEN=

View File

@ -149,7 +149,35 @@ Mission Control supports two authentication modes:
- `local`: shared bearer token mode (default for self-hosted use) - `local`: shared bearer token mode (default for self-hosted use)
- `clerk`: Clerk JWT mode - `clerk`: Clerk JWT mode
Environment templates: ### Local auth mode
Set `AUTH_MODE=local` (backend) and `NEXT_PUBLIC_AUTH_MODE=local` (frontend).
**Option 1 — Token in environment (recommended for self-hosted)**
Set `NEXT_PUBLIC_LOCAL_AUTH_TOKEN` in the frontend environment. The app loads directly with no login screen. The token is used automatically for every API call.
```env
NEXT_PUBLIC_AUTH_MODE=local
NEXT_PUBLIC_LOCAL_AUTH_TOKEN=your-bearer-token-here
```
**Option 2 — Token entered on load**
Leave `NEXT_PUBLIC_LOCAL_AUTH_TOKEN` unset. On first load, users are presented with a login screen and must paste their bearer token before accessing the app. The token is stored in `sessionStorage` for the duration of the browser session.
```env
NEXT_PUBLIC_AUTH_MODE=local
# NEXT_PUBLIC_LOCAL_AUTH_TOKEN is not set — login screen will appear
```
The bearer token must match `LOCAL_AUTH_TOKEN` set on the backend (minimum 50 characters).
### Clerk mode
Set `AUTH_MODE=clerk` and configure `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`.
### Environment templates
- Root: [`.env.example`](./.env.example) - Root: [`.env.example`](./.env.example)
- Backend: [`backend/.env.example`](./backend/.env.example) - Backend: [`backend/.env.example`](./backend/.env.example)

View File

@ -58,6 +58,7 @@ services:
args: args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-auto} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-auto}
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE} NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
NEXT_PUBLIC_LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
# Optional, user-managed env file. # Optional, user-managed env file.
# IMPORTANT: do NOT load `.env.example` here because it contains non-empty # IMPORTANT: do NOT load `.env.example` here because it contains non-empty
# placeholder Clerk keys, which can accidentally flip Clerk "on". # placeholder Clerk keys, which can accidentally flip Clerk "on".
@ -66,6 +67,7 @@ services:
environment: environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-auto} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-auto}
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE} NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
NEXT_PUBLIC_LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
depends_on: depends_on:
- backend - backend
ports: ports:

View File

@ -5,9 +5,15 @@ NEXT_PUBLIC_API_URL=auto
# Auth mode: clerk or local. # Auth mode: clerk or local.
# - clerk: Clerk sign-in flow # - clerk: Clerk sign-in flow
# - local: shared bearer token entered in UI # - local: shared bearer token, entered in UI or pre-set via env
NEXT_PUBLIC_AUTH_MODE=local NEXT_PUBLIC_AUTH_MODE=local
# Local auth token (used when NEXT_PUBLIC_AUTH_MODE=local).
# When set: the app loads directly without showing the login screen.
# When unset: users are prompted to enter their token on first load.
# Must be at least 50 characters and match LOCAL_AUTH_TOKEN on the backend.
NEXT_PUBLIC_LOCAL_AUTH_TOKEN=
# Clerk auth (used when NEXT_PUBLIC_AUTH_MODE=clerk) # Clerk auth (used when NEXT_PUBLIC_AUTH_MODE=clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards

View File

@ -9,6 +9,11 @@ export function isLocalAuthMode(): boolean {
return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Local; return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Local;
} }
/** Token pre-configured via env — skips the login screen when set. */
export function getEnvToken(): string | null {
return process.env.NEXT_PUBLIC_LOCAL_AUTH_TOKEN?.trim() || null;
}
export function setLocalAuthToken(token: string): void { export function setLocalAuthToken(token: string): void {
localToken = token; localToken = token;
if (typeof window === "undefined") return; if (typeof window === "undefined") return;

View File

@ -7,39 +7,9 @@ import { setLocalAuthToken } from "@/auth/localAuth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
const LOCAL_AUTH_TOKEN_MIN_LENGTH = 50; const LOCAL_AUTH_TOKEN_MIN_LENGTH = 50;
async function validateLocalToken(token: string): Promise<string | null> {
let baseUrl: string;
try {
baseUrl = getApiBaseUrl();
} catch {
return "Unable to resolve backend URL.";
}
let response: Response;
try {
response = await fetch(`${baseUrl}/api/v1/users/me`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
} catch {
return "Unable to reach backend to validate token.";
}
if (response.ok) {
return null;
}
if (response.status === 401 || response.status === 403) {
return "Token is invalid.";
}
return `Unable to validate token (HTTP ${response.status}).`;
}
type LocalAuthLoginProps = { type LocalAuthLoginProps = {
onAuthenticated?: () => void; onAuthenticated?: () => void;
}; };
@ -49,9 +19,8 @@ const defaultOnAuthenticated = () => window.location.reload();
export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) { export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isValidating, setIsValidating] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const cleaned = token.trim(); const cleaned = token.trim();
if (!cleaned) { if (!cleaned) {
@ -64,15 +33,6 @@ export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
); );
return; return;
} }
setIsValidating(true);
const validationError = await validateLocalToken(cleaned);
setIsValidating(false);
if (validationError) {
setError(validationError);
return;
}
setLocalAuthToken(cleaned); setLocalAuthToken(cleaned);
setError(null); setError(null);
(onAuthenticated ?? defaultOnAuthenticated)(); (onAuthenticated ?? defaultOnAuthenticated)();
@ -120,7 +80,6 @@ export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
onChange={(event) => setToken(event.target.value)} onChange={(event) => setToken(event.target.value)}
placeholder="Paste token" placeholder="Paste token"
autoFocus autoFocus
disabled={isValidating}
className="font-mono" className="font-mono"
/> />
</div> </div>
@ -133,13 +92,8 @@ export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
Token must be at least {LOCAL_AUTH_TOKEN_MIN_LENGTH} characters. Token must be at least {LOCAL_AUTH_TOKEN_MIN_LENGTH} characters.
</p> </p>
)} )}
<Button <Button type="submit" className="w-full" size="lg">
type="submit" Continue
className="w-full"
size="lg"
disabled={isValidating}
>
{isValidating ? "Validating..." : "Continue"}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>

View File

@ -6,8 +6,10 @@ import { useEffect, type ReactNode } from "react";
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
import { import {
clearLocalAuthToken, clearLocalAuthToken,
getEnvToken,
getLocalAuthToken, getLocalAuthToken,
isLocalAuthMode, isLocalAuthMode,
setLocalAuthToken,
} from "@/auth/localAuth"; } from "@/auth/localAuth";
import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin"; import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin";
@ -21,6 +23,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [localMode]); }, [localMode]);
if (localMode) { if (localMode) {
// If a token is pre-set via NEXT_PUBLIC_LOCAL_AUTH_TOKEN, use it and skip the login screen.
const envToken = getEnvToken();
if (envToken) {
setLocalAuthToken(envToken);
return <>{children}</>;
}
if (!getLocalAuthToken()) { if (!getLocalAuthToken()) {
return <LocalAuthLogin />; return <LocalAuthLogin />;
} }