refactor: replace hand-rolled OIDC with @logto/react SDK
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 48s

The hand-rolled OIDC flow (manual PKCE, token exchange, URL
construction) was fragile and accumulated multiple bugs. Replaced
with the official @logto/react SDK which handles PKCE, token
exchange, storage, and refresh automatically.

- Add @logto/react SDK dependency
- Add LogtoProvider with runtime config in main.tsx
- Add TokenSync component bridging SDK tokens to API client
- Add useAuth hook replacing Zustand auth store
- Simplify LoginPage to signIn(), CallbackPage to useHandleSignInCallback()
- Delete pkce.ts and auth-store.ts (replaced by SDK)
- Fix react-router-dom → react-router imports in page files
- All 17 React Query hooks unchanged (token provider pattern)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 01:17:47 +02:00
parent 84667170f1
commit 0843a33383
19 changed files with 320 additions and 224 deletions

View File

@@ -1,9 +1,19 @@
import { useAuthStore } from '../auth/auth-store';
const API_BASE = '/api';
let tokenProvider: (() => Promise<string | undefined>) | null = null;
export function setTokenProvider(provider: (() => Promise<string | undefined>) | null) {
tokenProvider = provider;
}
let logoutHandler: (() => void) | null = null;
export function setLogoutHandler(handler: (() => void) | null) {
logoutHandler = handler;
}
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = useAuthStore.getState().accessToken;
const token = tokenProvider ? await tokenProvider() : null;
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
@@ -17,7 +27,7 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (response.status === 401) {
useAuthStore.getState().logout();
if (logoutHandler) logoutHandler();
window.location.href = '/login';
throw new Error('Unauthorized');
}

View File

@@ -1,58 +1,21 @@
import { useEffect } from 'react';
import { useHandleSignInCallback } from '@logto/react';
import { useNavigate } from 'react-router';
import { useAuthStore } from './auth-store';
import { Spinner } from '@cameleer/design-system';
import { fetchConfig } from '../config';
import { getCodeVerifier } from './pkce';
export function CallbackPage() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) {
navigate('/login');
return;
}
const { isLoading } = useHandleSignInCallback(() => {
navigate('/', { replace: true });
});
const codeVerifier = getCodeVerifier();
if (!codeVerifier) {
navigate('/login');
return;
}
if (isLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Spinner />
</div>
);
}
const redirectUri = `${window.location.origin}/callback`;
fetchConfig().then((config) => {
fetch(`${config.logtoEndpoint}/oidc/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: config.logtoClientId,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
})
.then((r) => r.json())
.then((data) => {
if (data.access_token) {
login(data.access_token, data.refresh_token || '');
navigate('/');
} else {
navigate('/login');
}
})
.catch(() => navigate('/login'));
});
}, [login, navigate]);
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Spinner />
</div>
);
return null;
}

View File

@@ -1,20 +1,19 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useLogto } from '@logto/react';
import { useNavigate } from 'react-router';
import { Button, Spinner } from '@cameleer/design-system';
import { fetchConfig } from '../config';
import { generatePkce, storeCodeVerifier } from './pkce';
export function LoginPage() {
const [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string; logtoResource: string } | null>(null);
const [loading, setLoading] = useState(true);
const { signIn, isAuthenticated, isLoading } = useLogto();
const navigate = useNavigate();
useEffect(() => {
fetchConfig().then((c) => {
setConfig(c);
setLoading(false);
});
}, []);
if (isAuthenticated) {
navigate('/', { replace: true });
}
}, [isAuthenticated, navigate]);
if (loading) {
if (isLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Spinner />
@@ -22,23 +21,8 @@ export function LoginPage() {
);
}
const handleLogin = async () => {
if (!config?.logtoClientId) return;
const { codeVerifier, codeChallenge } = await generatePkce();
storeCodeVerifier(codeVerifier);
const redirectUri = `${window.location.origin}/callback`;
const params = new URLSearchParams({
client_id: config.logtoClientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email offline_access',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
if (config.logtoResource) {
params.set('resource', config.logtoResource);
}
window.location.href = `${config.logtoEndpoint}/oidc/auth?${params}`;
const handleLogin = () => {
signIn(`${window.location.origin}/callback`);
};
return (
@@ -48,13 +32,7 @@ export function LoginPage() {
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
Managed Apache Camel Runtime
</p>
{config?.logtoClientId ? (
<Button onClick={handleLogin}>Sign in with Logto</Button>
) : (
<p style={{ color: 'var(--color-text-secondary)' }}>
Identity provider not configured. Run the bootstrap script or check HOWTO.md.
</p>
)}
<Button onClick={handleLogin}>Sign in with Logto</Button>
</div>
</div>
);

View File

@@ -1,8 +1,18 @@
import { Navigate } from 'react-router';
import { useAuthStore } from './auth-store';
import { useLogto } from '@logto/react';
import { Spinner } from '@cameleer/design-system';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { isAuthenticated, isLoading } = useLogto();
if (isLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Spinner />
</div>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
}

View File

@@ -1,61 +0,0 @@
import { create } from 'zustand';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
username: string | null;
roles: string[];
tenantId: string | null;
isAuthenticated: boolean;
login: (accessToken: string, refreshToken: string) => void;
logout: () => void;
loadFromStorage: () => void;
}
function parseJwt(token: string): Record<string, unknown> {
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
} catch {
return {};
}
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
refreshToken: null,
username: null,
roles: [],
tenantId: null,
isAuthenticated: false,
login: (accessToken: string, refreshToken: string) => {
localStorage.setItem('cameleer-access-token', accessToken);
localStorage.setItem('cameleer-refresh-token', refreshToken);
const claims = parseJwt(accessToken);
const username = (claims.sub as string) || (claims.email as string) || 'user';
const roles = (claims.roles as string[]) || [];
const tenantId = (claims.organization_id as string) || null;
localStorage.setItem('cameleer-username', username);
set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('cameleer-access-token');
localStorage.removeItem('cameleer-refresh-token');
localStorage.removeItem('cameleer-username');
set({ accessToken: null, refreshToken: null, username: null, roles: [], tenantId: null, isAuthenticated: false });
},
loadFromStorage: () => {
const accessToken = localStorage.getItem('cameleer-access-token');
const refreshToken = localStorage.getItem('cameleer-refresh-token');
const username = localStorage.getItem('cameleer-username');
if (accessToken) {
const claims = parseJwt(accessToken);
const roles = (claims.roles as string[]) || [];
const tenantId = (claims.organization_id as string) || null;
set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
}
},
}));

View File

@@ -1,38 +0,0 @@
const VERIFIER_KEY = 'pkce_code_verifier';
function generateRandomString(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
}
async function sha256(plain: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
return crypto.subtle.digest('SHA-256', encoder.encode(plain));
}
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const b of bytes) {
binary += String.fromCharCode(b);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
const codeVerifier = generateRandomString(64);
const hashed = await sha256(codeVerifier);
const codeChallenge = base64UrlEncode(hashed);
return { codeVerifier, codeChallenge };
}
export function storeCodeVerifier(verifier: string): void {
sessionStorage.setItem(VERIFIER_KEY, verifier);
}
export function getCodeVerifier(): string | null {
const verifier = sessionStorage.getItem(VERIFIER_KEY);
sessionStorage.removeItem(VERIFIER_KEY);
return verifier;
}

43
ui/src/auth/useAuth.ts Normal file
View File

@@ -0,0 +1,43 @@
import { useLogto } from '@logto/react';
import { useState, useEffect, useCallback } from 'react';
interface IdTokenClaims {
sub?: string;
email?: string;
name?: string;
username?: string;
roles?: string[];
organization_id?: string;
[key: string]: unknown;
}
export function useAuth() {
const { isAuthenticated, isLoading, getIdTokenClaims, signOut, signIn } = useLogto();
const [claims, setClaims] = useState<IdTokenClaims | null>(null);
useEffect(() => {
if (isAuthenticated) {
getIdTokenClaims().then((c) => setClaims(c as IdTokenClaims));
} else {
setClaims(null);
}
}, [isAuthenticated, getIdTokenClaims]);
const username = claims?.username ?? claims?.name ?? claims?.email ?? claims?.sub ?? null;
const roles = (claims?.roles as string[]) ?? [];
const tenantId = (claims?.organization_id as string) ?? null;
const logout = useCallback(() => {
signOut(window.location.origin + '/login');
}, [signOut]);
return {
isAuthenticated,
isLoading,
username,
roles,
tenantId,
logout,
signIn,
};
}

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { SidebarTree, type SidebarTreeNode } from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useAuth } from '../auth/useAuth';
import { useEnvironments, useApps } from '../api/hooks';
import type { EnvironmentResponse } from '../types/api';
@@ -45,7 +45,7 @@ function EnvWithApps({
}
export function EnvironmentTree() {
const tenantId = useAuthStore((s) => s.tenantId);
const { tenantId } = useAuth();
const { data: environments } = useEnvironments(tenantId ?? '');
const navigate = useNavigate();
const location = useLocation();

View File

@@ -5,7 +5,7 @@ import {
Sidebar,
TopBar,
} from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useAuth } from '../auth/useAuth';
import { EnvironmentTree } from './EnvironmentTree';
// Simple SVG logo mark for the sidebar header
@@ -91,8 +91,7 @@ function UserIcon() {
export function Layout() {
const navigate = useNavigate();
const username = useAuthStore((s) => s.username);
const logout = useAuthStore((s) => s.logout);
const { username, logout } = useAuth();
const [envSectionOpen, setEnvSectionOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false);

View File

@@ -1,4 +1,4 @@
import { useAuthStore } from '../auth/auth-store';
import { useAuth } from '../auth/useAuth';
const ROLE_PERMISSIONS: Record<string, string[]> = {
OWNER: ['tenant:manage', 'billing:manage', 'team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
@@ -8,7 +8,7 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
};
export function usePermissions() {
const roles = useAuthStore((s) => s.roles);
const { roles } = useAuth();
const permissions = new Set<string>();
for (const role of roles) {

View File

@@ -1,10 +1,13 @@
import React from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom/client';
import { LogtoProvider, useLogto } from '@logto/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router';
import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
import { ThemeProvider, ToastProvider, BreadcrumbProvider, Spinner } from '@cameleer/design-system';
import '@cameleer/design-system/style.css';
import { AppRouter } from './router';
import { fetchConfig } from './config';
import { setTokenProvider, setLogoutHandler } from './api/client';
const queryClient = new QueryClient({
defaultOptions: {
@@ -15,16 +18,73 @@ const queryClient = new QueryClient({
},
});
function TokenSync({ resource }: { resource: string }) {
const { getAccessToken, isAuthenticated, signOut } = useLogto();
useEffect(() => {
if (isAuthenticated && resource) {
setTokenProvider(() => getAccessToken(resource));
} else {
setTokenProvider(null);
}
}, [isAuthenticated, getAccessToken, resource]);
const handleLogout = useCallback(() => {
signOut(window.location.origin + '/login');
}, [signOut]);
useEffect(() => {
setLogoutHandler(handleLogout);
return () => setLogoutHandler(null);
}, [handleLogout]);
return null;
}
function App() {
const [config, setConfig] = useState<{
logtoEndpoint: string;
logtoClientId: string;
logtoResource: string;
} | null>(null);
useEffect(() => {
fetchConfig().then(setConfig);
}, []);
if (!config?.logtoClientId) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Spinner />
</div>
);
}
return (
<LogtoProvider
config={{
endpoint: config.logtoEndpoint,
appId: config.logtoClientId,
resources: config.logtoResource ? [config.logtoResource] : [],
scopes: ['openid', 'profile', 'email', 'offline_access'],
}}
>
<TokenSync resource={config.logtoResource} />
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRouter />
</BrowserRouter>
</QueryClientProvider>
</LogtoProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<ToastProvider>
<BreadcrumbProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRouter />
</BrowserRouter>
</QueryClientProvider>
<App />
</BreadcrumbProvider>
</ToastProvider>
</ThemeProvider>

View File

@@ -1,5 +1,5 @@
import React, { useRef, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { useNavigate, useParams, Link } from 'react-router';
import {
Badge,
Button,
@@ -17,7 +17,7 @@ import {
useToast,
} from '@cameleer/design-system';
import type { Column, LogEntry as DSLogEntry } from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useAuth } from '../auth/useAuth';
import {
useApp,
useDeployment,
@@ -118,7 +118,7 @@ export function AppDetailPage() {
const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const tenantId = useAuthStore((s) => s.tenantId);
const { tenantId } = useAuth();
const { canManageApps, canDeploy } = usePermissions();
// Active tab

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import {
Badge,
Button,
@@ -8,7 +8,7 @@ import {
KpiStrip,
Spinner,
} from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useAuth } from '../auth/useAuth';
import { useTenant, useEnvironments, useApps } from '../api/hooks';
import { RequirePermission } from '../components/RequirePermission';
import type { EnvironmentResponse, AppResponse } from '../types/api';
@@ -41,7 +41,7 @@ function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
export function DashboardPage() {
const navigate = useNavigate();
const tenantId = useAuthStore((s) => s.tenantId);
const { tenantId } = useAuth();
const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? '');
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');

View File

@@ -1,5 +1,5 @@
import React, { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router';
import {
Badge,
Button,
@@ -15,7 +15,7 @@ import {
useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useAuth } from '../auth/useAuth';
import {
useEnvironments,
useUpdateEnvironment,
@@ -77,7 +77,7 @@ export function EnvironmentDetailPage() {
const navigate = useNavigate();
const { envId } = useParams<{ envId: string }>();
const { toast } = useToast();
const tenantId = useAuthStore((s) => s.tenantId);
const { tenantId } = useAuth();
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
const environment = environments?.find((e) => e.id === envId);

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import {
Badge,
Button,
@@ -13,7 +13,7 @@ import {
useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useAuth } from '../auth/useAuth';
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
import { RequirePermission } from '../components/RequirePermission';
import type { EnvironmentResponse } from '../types/api';
@@ -67,7 +67,7 @@ const columns: Column<TableRow>[] = [
export function EnvironmentsPage() {
const navigate = useNavigate();
const { toast } = useToast();
const tenantId = useAuthStore((s) => s.tenantId);
const { tenantId } = useAuth();
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
const createMutation = useCreateEnvironment(tenantId ?? '');

View File

@@ -5,7 +5,7 @@ import {
EmptyState,
Spinner,
} from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useAuth } from '../auth/useAuth';
import { useLicense } from '../api/hooks';
const FEATURE_LABELS: Record<string, string> = {
@@ -39,7 +39,7 @@ function daysRemaining(expiresAt: string): number {
}
export function LicensePage() {
const tenantId = useAuthStore((s) => s.tenantId);
const { tenantId } = useAuth();
const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
const [tokenExpanded, setTokenExpanded] = useState(false);

View File

@@ -1,6 +1,4 @@
import { Routes, Route } from 'react-router';
import { useEffect } from 'react';
import { useAuthStore } from './auth/auth-store';
import { LoginPage } from './auth/LoginPage';
import { CallbackPage } from './auth/CallbackPage';
import { ProtectedRoute } from './auth/ProtectedRoute';
@@ -12,11 +10,6 @@ import { AppDetailPage } from './pages/AppDetailPage';
import { LicensePage } from './pages/LicensePage';
export function AppRouter() {
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
useEffect(() => {
loadFromStorage();
}, [loadFromStorage]);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />