refactor: architecture cleanup — OIDC dedup, PKCE, K8s hardening
- Extract OidcProviderHelper for shared discovery + JWK source construction - Add SystemRole.normalizeScope() to centralize role normalization - Merge duplicate claim extraction in OidcTokenExchanger - Add PKCE (S256) to OIDC authorization flow (frontend + backend) - Add SecurityContext (runAsNonRoot) to all K8s deployments - Fix postgres probe to use $POSTGRES_USER instead of hardcoded username - Remove default credentials from Dockerfile - Extract sanitize_branch() to shared .gitea/sanitize-branch.sh - Fix sidebar to use /exchanges/ paths directly, remove legacy redirects - Centralize basePath computation in router.tsx via config module Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
ui/src/api/schema.d.ts
vendored
1
ui/src/api/schema.d.ts
vendored
@@ -1611,6 +1611,7 @@ export interface components {
|
||||
CallbackRequest: {
|
||||
code?: string;
|
||||
redirectUri?: string;
|
||||
codeVerifier?: string;
|
||||
};
|
||||
LoginRequest: {
|
||||
username?: string;
|
||||
|
||||
@@ -11,6 +11,22 @@ interface OidcInfo {
|
||||
authorizationEndpoint: string;
|
||||
}
|
||||
|
||||
/** Generate a random code_verifier for PKCE (RFC 7636). */
|
||||
function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Derive the S256 code_challenge from a code_verifier. */
|
||||
async function deriveCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
const SUBTITLES = [
|
||||
"Prove you're not a mirage",
|
||||
"Only authorized cameleers beyond this dune",
|
||||
@@ -68,14 +84,20 @@ export function LoginPage() {
|
||||
if (oidc && !forceLocal && !autoRedirected.current) {
|
||||
autoRedirected.current = true;
|
||||
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: oidc.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid email profile',
|
||||
prompt: 'none',
|
||||
const verifier = generateCodeVerifier();
|
||||
sessionStorage.setItem('oidc-code-verifier', verifier);
|
||||
deriveCodeChallenge(verifier).then((challenge) => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: oidc.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid email profile',
|
||||
prompt: 'none',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
||||
});
|
||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
||||
}
|
||||
}, [oidc, forceLocal]);
|
||||
|
||||
@@ -86,15 +108,20 @@ export function LoginPage() {
|
||||
login(username, password);
|
||||
};
|
||||
|
||||
const handleOidcLogin = () => {
|
||||
const handleOidcLogin = async () => {
|
||||
if (!oidc) return;
|
||||
setOidcLoading(true);
|
||||
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||||
const verifier = generateCodeVerifier();
|
||||
sessionStorage.setItem('oidc-code-verifier', verifier);
|
||||
const challenge = await deriveCodeChallenge(verifier);
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: oidc.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid email profile',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
||||
};
|
||||
|
||||
@@ -59,7 +59,9 @@ export function OidcCallback() {
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||||
loginWithOidcCode(code, redirectUri);
|
||||
const codeVerifier = sessionStorage.getItem('oidc-code-verifier') ?? undefined;
|
||||
sessionStorage.removeItem('oidc-code-verifier');
|
||||
loginWithOidcCode(code, redirectUri, codeVerifier);
|
||||
}, [loginWithOidcCode]);
|
||||
|
||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||
|
||||
@@ -11,7 +11,7 @@ interface AuthState {
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
|
||||
loginWithOidcCode: (code: string, redirectUri: string, codeVerifier?: string) => Promise<void>;
|
||||
refresh: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
@@ -86,11 +86,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginWithOidcCode: async (code, redirectUri) => {
|
||||
loginWithOidcCode: async (code, redirectUri, codeVerifier?) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { data, error } = await api.POST('/auth/oidc/callback', {
|
||||
body: { code, redirectUri },
|
||||
body: { code, redirectUri, codeVerifier },
|
||||
});
|
||||
if (error || !data) {
|
||||
throw new Error('OIDC login failed');
|
||||
|
||||
@@ -58,7 +58,7 @@ export function writeCollapsed(key: string, value: boolean): void {
|
||||
|
||||
/**
|
||||
* Apps tree — one node per app, routes as children.
|
||||
* Paths: /apps/{appId}, /apps/{appId}/{routeId}
|
||||
* Paths: /exchanges/{appId}, /exchanges/{appId}/{routeId}
|
||||
*/
|
||||
export function buildAppTreeNodes(
|
||||
apps: SidebarApp[],
|
||||
@@ -72,7 +72,7 @@ export function buildAppTreeNodes(
|
||||
label: app.name,
|
||||
icon: statusDot(app.health),
|
||||
badge: formatCount(app.exchangeCount),
|
||||
path: `/apps/${app.id}`,
|
||||
path: `/exchanges/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `app:${app.id}`,
|
||||
children: app.routes.map((r) => ({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createBrowserRouter, Navigate, useParams } from 'react-router';
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { RequireAdmin } from './auth/RequireAdmin';
|
||||
import { LoginPage } from './auth/LoginPage';
|
||||
import { OidcCallback } from './auth/OidcCallback';
|
||||
import { LayoutShell } from './components/LayoutShell';
|
||||
import { config } from './config';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Spinner } from '@cameleer/design-system';
|
||||
|
||||
@@ -28,21 +29,7 @@ function SuspenseWrapper({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirect legacy /apps/:appId/:routeId paths to /exchanges/:appId/:routeId */
|
||||
function LegacyAppRedirect() {
|
||||
const { appId, routeId } = useParams<{ appId: string; routeId?: string }>();
|
||||
const path = routeId ? `/exchanges/${appId}/${routeId}` : `/exchanges/${appId}`;
|
||||
return <Navigate to={path} replace />;
|
||||
}
|
||||
|
||||
/** Redirect legacy /agents/:appId/:instanceId paths to /runtime/:appId/:instanceId */
|
||||
function LegacyAgentRedirect() {
|
||||
const { appId, instanceId } = useParams<{ appId: string; instanceId?: string }>();
|
||||
const path = instanceId ? `/runtime/${appId}/${instanceId}` : `/runtime/${appId}`;
|
||||
return <Navigate to={path} replace />;
|
||||
}
|
||||
|
||||
const basename = document.querySelector('base')?.getAttribute('href')?.replace(/\/$/, '') || '';
|
||||
const basename = config.basePath.replace(/\/$/, '') || undefined;
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
@@ -81,14 +68,6 @@ export const router = createBrowserRouter([
|
||||
{ path: 'config', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'config/:appId', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||
|
||||
// Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths
|
||||
{ path: 'apps', element: <Navigate to="/exchanges" replace /> },
|
||||
{ path: 'apps/:appId', element: <LegacyAppRedirect /> },
|
||||
{ path: 'apps/:appId/:routeId', element: <LegacyAppRedirect /> },
|
||||
{ path: 'agents', element: <Navigate to="/runtime" replace /> },
|
||||
{ path: 'agents/:appId', element: <LegacyAgentRedirect /> },
|
||||
{ path: 'agents/:appId/:instanceId', element: <LegacyAgentRedirect /> },
|
||||
|
||||
// Admin (ADMIN role required)
|
||||
{
|
||||
element: <RequireAdmin />,
|
||||
@@ -110,4 +89,4 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
], { basename: basename || undefined });
|
||||
], { basename });
|
||||
|
||||
Reference in New Issue
Block a user