refactor: replace hand-rolled OIDC with @logto/react SDK
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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
43
ui/src/auth/useAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user