feat: auth token env bypass
This commit is contained in:
parent
b2766ba063
commit
72b873845f
|
|
@ -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=
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue