diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java index 19321f89..f84995ca 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java @@ -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); diff --git a/ui/src/auth/LoginPage.module.css b/ui/src/auth/LoginPage.module.css index 51c863b8..70da3f8d 100644 --- a/ui/src/auth/LoginPage.module.css +++ b/ui/src/auth/LoginPage.module.css @@ -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; diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index 24c755fd..1c064c22 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -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(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 ; @@ -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 (
@@ -27,6 +59,22 @@ export function LoginPage() {
Sign in to access the observability dashboard
+ {oidc && ( + <> + +
+ or +
+ + )} +
{ + 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 ; + + return ( +
+
+
+ + + + + cameleer3 +
+ {loading &&
Completing sign-in...
} + {error && ( + <> +
{error}
+ + + )} +
+
+ ); +} diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index 35bf9d95..294914b4 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -10,6 +10,7 @@ interface AuthState { error: string | null; loading: boolean; login: (username: string, password: string) => Promise; + loginWithOidcCode: (code: string, redirectUri: string) => Promise; refresh: () => Promise; logout: () => void; } @@ -84,6 +85,38 @@ export const useAuthStore = create((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; diff --git a/ui/src/pages/admin/OidcAdminPage.tsx b/ui/src/pages/admin/OidcAdminPage.tsx index 6870ab07..e65ebb69 100644 --- a/ui/src/pages/admin/OidcAdminPage.tsx +++ b/ui/src/pages/admin/OidcAdminPage.tsx @@ -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" />
diff --git a/ui/src/pages/executions/SearchFilters.module.css b/ui/src/pages/executions/SearchFilters.module.css index 5b9949a3..c001d141 100644 --- a/ui/src/pages/executions/SearchFilters.module.css +++ b/ui/src/pages/executions/SearchFilters.module.css @@ -111,6 +111,7 @@ outline: none; width: 180px; transition: border-color 0.2s; + color-scheme: light dark; } .dateInput:focus { border-color: var(--amber-dim); } diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 8d0bd646..269aa499 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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: , }, + { + path: '/oidc/callback', + element: , + }, { element: , children: [