Add OIDC login flow to UI and fix dark mode datetime picker icons
- Add "Sign in with SSO" button on login page (shown when OIDC is configured) - Add /oidc/callback route to exchange authorization code for JWT tokens - Add loginWithOidcCode action to auth store - Treat issuer URI as complete discovery URL (no auto-append of .well-known) - Update admin page placeholder to show full discovery URL format - Fix datetime picker calendar icon visibility in dark mode (color-scheme) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,11 +21,15 @@ import com.nimbusds.oauth2.sdk.auth.Secret;
|
||||
import com.nimbusds.oauth2.sdk.id.ClientID;
|
||||
import com.nimbusds.oauth2.sdk.id.Issuer;
|
||||
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
|
||||
import net.minidev.json.JSONObject;
|
||||
import net.minidev.json.parser.JSONParser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -159,8 +163,15 @@ public class OidcTokenExchanger {
|
||||
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
||||
synchronized (this) {
|
||||
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
||||
Issuer issuer = new Issuer(issuerUri);
|
||||
providerMetadata = OIDCProviderMetadata.resolve(issuer);
|
||||
// Fetch the discovery document from the URI as-is — do not append
|
||||
// .well-known/openid-configuration automatically, the user provides
|
||||
// the complete URL.
|
||||
URL discoveryUrl = new URI(issuerUri).toURL();
|
||||
try (InputStream in = discoveryUrl.openStream()) {
|
||||
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE)
|
||||
.parse(in);
|
||||
providerMetadata = OIDCProviderMetadata.parse(json);
|
||||
}
|
||||
cachedIssuerUri = issuerUri;
|
||||
jwtProcessor = null; // Reset processor when issuer changes
|
||||
log.info("OIDC provider metadata loaded from {}", issuerUri);
|
||||
|
||||
@@ -92,6 +92,48 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ssoButton {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.ssoButton:hover {
|
||||
border-color: var(--amber-dim);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.ssoButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.dividerText {
|
||||
position: relative;
|
||||
top: -0.65em;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-surface);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAuthStore } from './auth-store';
|
||||
import { config } from '../config';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
interface OidcInfo {
|
||||
clientId: string;
|
||||
authorizationEndpoint: string;
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { isAuthenticated, login, loading, error } = useAuthStore();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [oidc, setOidc] = useState<OidcInfo | null>(null);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${config.apiBaseUrl}/auth/oidc/config`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.authorizationEndpoint && data?.clientId) {
|
||||
setOidc({ clientId: data.clientId, authorizationEndpoint: data.authorizationEndpoint });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||
|
||||
@@ -15,6 +34,19 @@ export function LoginPage() {
|
||||
login(username, password);
|
||||
};
|
||||
|
||||
const handleOidcLogin = () => {
|
||||
if (!oidc) return;
|
||||
setOidcLoading(true);
|
||||
const redirectUri = `${window.location.origin}/oidc/callback`;
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: oidc.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid email profile',
|
||||
});
|
||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<form className={styles.card} onSubmit={handleSubmit}>
|
||||
@@ -27,6 +59,22 @@ export function LoginPage() {
|
||||
</div>
|
||||
<div className={styles.subtitle}>Sign in to access the observability dashboard</div>
|
||||
|
||||
{oidc && (
|
||||
<>
|
||||
<button
|
||||
className={styles.ssoButton}
|
||||
type="button"
|
||||
onClick={handleOidcLogin}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
||||
</button>
|
||||
<div className={styles.divider}>
|
||||
<span className={styles.dividerText}>or</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Username</label>
|
||||
<input
|
||||
|
||||
64
ui/src/auth/OidcCallback.tsx
Normal file
64
ui/src/auth/OidcCallback.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router';
|
||||
import { useAuthStore } from './auth-store';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
export function OidcCallback() {
|
||||
const { isAuthenticated, loading, error, loginWithOidcCode } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const exchanged = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (exchanged.current) return;
|
||||
exchanged.current = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const errorParam = params.get('error');
|
||||
|
||||
if (errorParam) {
|
||||
useAuthStore.setState({
|
||||
error: params.get('error_description') || errorParam,
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
useAuthStore.setState({ error: 'No authorization code received', loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/oidc/callback`;
|
||||
loginWithOidcCode(code, redirectUri);
|
||||
}, [loginWithOidcCode]);
|
||||
|
||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.logo}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
cameleer3
|
||||
</div>
|
||||
{loading && <div className={styles.subtitle}>Completing sign-in...</div>}
|
||||
{error && (
|
||||
<>
|
||||
<div className={styles.error}>{error}</div>
|
||||
<button
|
||||
className={styles.submit}
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface AuthState {
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
|
||||
refresh: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
@@ -84,6 +85,38 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginWithOidcCode: async (code, redirectUri) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await fetch(`${config.apiBaseUrl}/auth/oidc/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirectUri }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.message || 'OIDC login failed');
|
||||
}
|
||||
const { accessToken, refreshToken } = await res.json();
|
||||
const payload = JSON.parse(atob(accessToken.split('.')[1]));
|
||||
const username = payload.sub ?? 'oidc-user';
|
||||
persistTokens(accessToken, refreshToken, username);
|
||||
set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
username,
|
||||
roles: parseRolesFromJwt(accessToken),
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
set({
|
||||
error: e instanceof Error ? e.message : 'OIDC login failed',
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const { refreshToken } = get();
|
||||
if (!refreshToken) return false;
|
||||
|
||||
@@ -194,7 +194,7 @@ function OidcAdminForm() {
|
||||
type="url"
|
||||
value={form.issuerUri}
|
||||
onChange={(e) => updateField('issuerUri', e.target.value)}
|
||||
placeholder="https://auth.example.com/realms/main"
|
||||
placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
outline: none;
|
||||
width: 180px;
|
||||
transition: border-color 0.2s;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.dateInput:focus { border-color: var(--amber-dim); }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { LoginPage } from './auth/LoginPage';
|
||||
import { OidcCallback } from './auth/OidcCallback';
|
||||
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
||||
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
||||
|
||||
@@ -10,6 +11,10 @@ export const router = createBrowserRouter([
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/oidc/callback',
|
||||
element: <OidcCallback />,
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
|
||||
Reference in New Issue
Block a user