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.ClientID;
|
||||||
import com.nimbusds.oauth2.sdk.id.Issuer;
|
import com.nimbusds.oauth2.sdk.id.Issuer;
|
||||||
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.net.URL;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -159,8 +163,15 @@ public class OidcTokenExchanger {
|
|||||||
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
||||||
Issuer issuer = new Issuer(issuerUri);
|
// Fetch the discovery document from the URI as-is — do not append
|
||||||
providerMetadata = OIDCProviderMetadata.resolve(issuer);
|
// .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;
|
cachedIssuerUri = issuerUri;
|
||||||
jwtProcessor = null; // Reset processor when issuer changes
|
jwtProcessor = null; // Reset processor when issuer changes
|
||||||
log.info("OIDC provider metadata loaded from {}", issuerUri);
|
log.info("OIDC provider metadata loaded from {}", issuerUri);
|
||||||
|
|||||||
@@ -92,6 +92,48 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.error {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 10px 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 { Navigate } from 'react-router';
|
||||||
import { useAuthStore } from './auth-store';
|
import { useAuthStore } from './auth-store';
|
||||||
|
import { config } from '../config';
|
||||||
import styles from './LoginPage.module.css';
|
import styles from './LoginPage.module.css';
|
||||||
|
|
||||||
|
interface OidcInfo {
|
||||||
|
clientId: string;
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { isAuthenticated, login, loading, error } = useAuthStore();
|
const { isAuthenticated, login, loading, error } = useAuthStore();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = 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 />;
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
@@ -15,6 +34,19 @@ export function LoginPage() {
|
|||||||
login(username, password);
|
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 (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<form className={styles.card} onSubmit={handleSubmit}>
|
<form className={styles.card} onSubmit={handleSubmit}>
|
||||||
@@ -27,6 +59,22 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.subtitle}>Sign in to access the observability dashboard</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}>
|
<div className={styles.field}>
|
||||||
<label className={styles.label}>Username</label>
|
<label className={styles.label}>Username</label>
|
||||||
<input
|
<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;
|
error: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
|
||||||
refresh: () => Promise<boolean>;
|
refresh: () => Promise<boolean>;
|
||||||
logout: () => void;
|
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 () => {
|
refresh: async () => {
|
||||||
const { refreshToken } = get();
|
const { refreshToken } = get();
|
||||||
if (!refreshToken) return false;
|
if (!refreshToken) return false;
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function OidcAdminForm() {
|
|||||||
type="url"
|
type="url"
|
||||||
value={form.issuerUri}
|
value={form.issuerUri}
|
||||||
onChange={(e) => updateField('issuerUri', e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dateInput:focus { border-color: var(--amber-dim); }
|
.dateInput:focus { border-color: var(--amber-dim); }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from 'react-router';
|
|||||||
import { AppShell } from './components/layout/AppShell';
|
import { AppShell } from './components/layout/AppShell';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
import { LoginPage } from './auth/LoginPage';
|
import { LoginPage } from './auth/LoginPage';
|
||||||
|
import { OidcCallback } from './auth/OidcCallback';
|
||||||
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
||||||
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@ export const router = createBrowserRouter([
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
element: <LoginPage />,
|
element: <LoginPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/oidc/callback',
|
||||||
|
element: <OidcCallback />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
element: <ProtectedRoute />,
|
element: <ProtectedRoute />,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
Reference in New Issue
Block a user