Add OIDC login flow to UI and fix dark mode datetime picker icons
All checks were successful
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 31s

- 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:
hsiegeln
2026-03-14 14:19:06 +01:00
parent b024f83c26
commit 84f4c505a2
8 changed files with 208 additions and 4 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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

View 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>
);
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -111,6 +111,7 @@
outline: none;
width: 180px;
transition: border-color 0.2s;
color-scheme: light dark;
}
.dateInput:focus { border-color: var(--amber-dim); }

View File

@@ -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: [