Add OIDC logout, fix OpenAPI schema types, expose end_session_endpoint
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 51s
CI / deploy (push) Successful in 29s

Backend:
- Expose end_session_endpoint from OIDC provider metadata in /auth/oidc/config
- Add getEndSessionEndpoint() to OidcTokenExchanger

Frontend:
- On OIDC logout, redirect to provider's end_session_endpoint to clear SSO session
- Strip /api/v1 prefix from OpenAPI paths to match client baseUrl convention
- Add schema-types.ts with convenience type re-exports from generated schema
- Fix all type imports to use schema-types instead of raw generated schema
- Fix optional field access (processors, children, duration) with proper typing
- Fix AgentInstance.state → status field name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 14:43:18 +01:00
parent 0d82304cf0
commit 50bb22d6f6
15 changed files with 1755 additions and 53 deletions

View File

@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.net.URI; import java.net.URI;
import java.time.Instant; import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -60,11 +61,15 @@ public class OidcAuthController {
try { try {
OidcConfig oidc = config.get(); OidcConfig oidc = config.get();
return ResponseEntity.ok(Map.of( Map<String, Object> response = new LinkedHashMap<>();
"issuer", oidc.issuerUri(), response.put("issuer", oidc.issuerUri());
"clientId", oidc.clientId(), response.put("clientId", oidc.clientId());
"authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint() response.put("authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint());
)); String endSessionEndpoint = tokenExchanger.getEndSessionEndpoint();
if (endSessionEndpoint != null) {
response.put("endSessionEndpoint", endSessionEndpoint);
}
return ResponseEntity.ok(response);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to retrieve OIDC provider metadata: {}", e.getMessage()); log.error("Failed to retrieve OIDC provider metadata: {}", e.getMessage());
return ResponseEntity.internalServerError() return ResponseEntity.internalServerError()

View File

@@ -114,6 +114,15 @@ public class OidcTokenExchanger {
return getProviderMetadata(config.issuerUri()).getAuthorizationEndpointURI().toString(); return getProviderMetadata(config.issuerUri()).getAuthorizationEndpointURI().toString();
} }
/**
* Returns the provider's end-session (logout) endpoint, or {@code null} if not advertised.
*/
public String getEndSessionEndpoint() throws Exception {
OidcConfig config = getConfig();
URI uri = getProviderMetadata(config.issuerUri()).getEndSessionEndpointURI();
return uri != null ? uri.toString() : null;
}
/** /**
* Invalidates cached provider metadata and JWKS processor. * Invalidates cached provider metadata and JWKS processor.
* Call after OIDC configuration is updated in the database. * Call after OIDC configuration is updated in the database.

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,12 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../client'; import { api } from '../client';
import type { SearchRequest } from '../schema'; import type {
SearchRequest,
ExecutionStats,
ExecutionSummary,
StatsTimeseries,
ExecutionDetail,
} from '../schema-types';
export function useExecutionStats(timeFrom: string | undefined, timeTo: string | undefined) { export function useExecutionStats(timeFrom: string | undefined, timeTo: string | undefined) {
return useQuery({ return useQuery({
@@ -15,7 +21,7 @@ export function useExecutionStats(timeFrom: string | undefined, timeTo: string |
}, },
}); });
if (error) throw new Error('Failed to load stats'); if (error) throw new Error('Failed to load stats');
return data!; return data as unknown as ExecutionStats;
}, },
enabled: !!timeFrom, enabled: !!timeFrom,
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
@@ -31,7 +37,7 @@ export function useSearchExecutions(filters: SearchRequest, live = false) {
body: filters, body: filters,
}); });
if (error) throw new Error('Search failed'); if (error) throw new Error('Search failed');
return data!; return data as unknown as { data: ExecutionSummary[]; total: number; offset: number; limit: number };
}, },
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
refetchInterval: live ? 5_000 : false, refetchInterval: live ? 5_000 : false,
@@ -52,7 +58,7 @@ export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string
}, },
}); });
if (error) throw new Error('Failed to load timeseries'); if (error) throw new Error('Failed to load timeseries');
return data!; return data as unknown as StatsTimeseries;
}, },
enabled: !!timeFrom, enabled: !!timeFrom,
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
@@ -68,7 +74,7 @@ export function useExecutionDetail(executionId: string | null) {
params: { path: { executionId: executionId! } }, params: { path: { executionId: executionId! } },
}); });
if (error) throw new Error('Failed to load execution detail'); if (error) throw new Error('Failed to load execution detail');
return data!; return data as unknown as ExecutionDetail;
}, },
enabled: !!executionId, enabled: !!executionId,
}); });
@@ -90,7 +96,7 @@ export function useProcessorSnapshot(
}, },
); );
if (error) throw new Error('Failed to load snapshot'); if (error) throw new Error('Failed to load snapshot');
return data!; return data as unknown as Record<string, string>;
}, },
enabled: !!executionId && index !== null, enabled: !!executionId && index !== null,
}); });

View File

@@ -0,0 +1,31 @@
import type { components } from './schema';
type Require<T> = {
[K in keyof T]-?: T[K] extends (infer U)[]
? Require<U>[]
: T[K] extends object | undefined
? Require<NonNullable<T[K]>>
: NonNullable<T[K]>;
};
export type ExecutionSummary = Require<components['schemas']['ExecutionSummary']>;
export type SearchRequest = components['schemas']['SearchRequest'];
export type ExecutionDetail = Require<components['schemas']['ExecutionDetail']>;
export type ExecutionStats = Require<components['schemas']['ExecutionStats']>;
export type StatsTimeseries = Require<components['schemas']['StatsTimeseries']>;
export type TimeseriesBucket = Require<components['schemas']['TimeseriesBucket']>;
export type UserInfo = Require<components['schemas']['UserInfo']>;
export type ProcessorNode = Require<components['schemas']['ProcessorNode']> & {
children?: ProcessorNode[];
};
export interface AgentInstance {
id: string;
applicationName: string;
group: string;
status: string;
routeIds: string[];
registeredAt: string;
lastHeartbeat: string;
}

View File

@@ -4,7 +4,7 @@
*/ */
export interface paths { export interface paths {
"/api/v1/admin/users/{userId}/roles": { "/admin/users/{userId}/roles": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -21,7 +21,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/admin/oidc": { "/admin/oidc": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -40,7 +40,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/search/executions": { "/search/executions": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -58,7 +58,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/data/metrics": { "/data/metrics": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -78,7 +78,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/data/executions": { "/data/executions": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -98,7 +98,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/data/diagrams": { "/data/diagrams": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -118,7 +118,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/auth/refresh": { "/auth/refresh": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -134,7 +134,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/auth/oidc/callback": { "/auth/oidc/callback": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -150,7 +150,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/auth/login": { "/auth/login": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -166,7 +166,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents/{id}/refresh": { "/agents/{id}/refresh": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -186,7 +186,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents/{id}/heartbeat": { "/agents/{id}/heartbeat": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -206,7 +206,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents/{id}/commands": { "/agents/{id}/commands": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -226,7 +226,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents/{id}/commands/{commandId}/ack": { "/agents/{id}/commands/{commandId}/ack": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -246,7 +246,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents/register": { "/agents/register": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -266,7 +266,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents/groups/{group}/commands": { "/agents/groups/{group}/commands": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -286,7 +286,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents/commands": { "/agents/commands": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -306,7 +306,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/admin/oidc/test": { "/admin/oidc/test": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -323,7 +323,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/search/stats": { "/search/stats": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -340,7 +340,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/search/stats/timeseries": { "/search/stats/timeseries": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -357,7 +357,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/executions/{executionId}": { "/executions/{executionId}": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -374,7 +374,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/executions/{executionId}/processors/{index}/snapshot": { "/executions/{executionId}/processors/{index}/snapshot": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -391,7 +391,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/diagrams/{contentHash}/render": { "/diagrams/{contentHash}/render": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -411,7 +411,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/auth/oidc/config": { "/auth/oidc/config": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -427,7 +427,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents": { "/agents": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -447,7 +447,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agents/{id}/events": { "/agents/{id}/events": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -467,7 +467,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/admin/users": { "/admin/users": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -484,7 +484,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/admin/users/{userId}": { "/admin/users/{userId}": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;

View File

@@ -22,6 +22,9 @@ export function LoginPage() {
.then((data) => { .then((data) => {
if (data?.authorizationEndpoint && data?.clientId) { if (data?.authorizationEndpoint && data?.clientId) {
setOidc({ clientId: data.clientId, authorizationEndpoint: data.authorizationEndpoint }); setOidc({ clientId: data.clientId, authorizationEndpoint: data.authorizationEndpoint });
if (data.endSessionEndpoint) {
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
}
} }
}) })
.catch(() => {}); .catch(() => {});

View File

@@ -68,6 +68,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
throw new Error(body.message || 'Invalid credentials'); throw new Error(body.message || 'Invalid credentials');
} }
const { accessToken, refreshToken } = await res.json(); const { accessToken, refreshToken } = await res.json();
localStorage.removeItem('cameleer-oidc-end-session');
persistTokens(accessToken, refreshToken, username); persistTokens(accessToken, refreshToken, username);
set({ set({
accessToken, accessToken,
@@ -143,7 +144,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}, },
logout: () => { logout: () => {
const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session');
clearTokens(); clearTokens();
localStorage.removeItem('cameleer-oidc-end-session');
set({ set({
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
@@ -152,5 +155,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: false, isAuthenticated: false,
error: null, error: null,
}); });
if (endSessionEndpoint) {
const postLogoutRedirect = `${window.location.origin}/login`;
const params = new URLSearchParams({
post_logout_redirect_uri: postLogoutRedirect,
});
window.location.href = `${endSessionEndpoint}?${params}`;
}
}, },
})); }));

View File

@@ -1,4 +1,4 @@
import type { ExecutionSummary, AgentInstance } from '../../api/schema'; import type { ExecutionSummary, AgentInstance } from '../../api/schema-types';
import type { PaletteResult, RouteInfo } from './use-palette-search'; import type { PaletteResult, RouteInfo } from './use-palette-search';
import { highlightMatch, formatRelativeTime } from './utils'; import { highlightMatch, formatRelativeTime } from './utils';
import { AppBadge } from '../shared/AppBadge'; import { AppBadge } from '../shared/AppBadge';
@@ -93,7 +93,7 @@ function ApplicationResult({ data, query }: { data: AgentInstance; query: string
<div className={styles.resultBody}> <div className={styles.resultBody}>
<div className={styles.resultTitle}> <div className={styles.resultTitle}>
<HighlightedText text={data.id} query={query} /> <HighlightedText text={data.id} query={query} />
<span className={stateBadgeClass(data.state)}>{data.state}</span> <span className={stateBadgeClass(data.status)}>{data.status}</span>
</div> </div>
<div className={styles.resultMeta}> <div className={styles.resultMeta}>
<span>group: {data.group}</span> <span>group: {data.group}</span>

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { ExecutionSummary, AgentInstance } from '../../api/schema'; import type { ExecutionSummary, AgentInstance } from '../../api/schema-types';
import { useCommandPalette, type PaletteScope } from './use-command-palette'; import { useCommandPalette, type PaletteScope } from './use-command-palette';
import { useDebouncedValue } from './utils'; import { useDebouncedValue } from './utils';
@@ -51,7 +51,7 @@ export function usePaletteSearch() {
}, },
}); });
if (error) throw new Error('Search failed'); if (error) throw new Error('Search failed');
return data!; return data as unknown as { data: ExecutionSummary[]; total: number };
}, },
enabled: isOpen && isExecutionScope(scope), enabled: isOpen && isExecutionScope(scope),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
@@ -64,7 +64,7 @@ export function usePaletteSearch() {
params: { query: {} }, params: { query: {} },
}); });
if (error) throw new Error('Failed to load agents'); if (error) throw new Error('Failed to load agents');
return data!; return data as unknown as AgentInstance[];
}, },
enabled: isOpen && (isApplicationScope(scope) || isRouteScope(scope)), enabled: isOpen && (isApplicationScope(scope) || isRouteScope(scope)),
staleTime: 30_000, staleTime: 30_000,

View File

@@ -1,5 +1,5 @@
import { useProcessorSnapshot } from '../../api/queries/executions'; import { useProcessorSnapshot } from '../../api/queries/executions';
import type { ExecutionSummary } from '../../api/schema'; import type { ExecutionSummary } from '../../api/schema-types';
import styles from './ExchangeDetail.module.css'; import styles from './ExchangeDetail.module.css';
interface ExchangeDetailProps { interface ExchangeDetailProps {

View File

@@ -1,5 +1,5 @@
import { useExecutionDetail } from '../../api/queries/executions'; import { useExecutionDetail } from '../../api/queries/executions';
import type { ProcessorNode as ProcessorNodeType } from '../../api/schema'; import type { ProcessorNode as ProcessorNodeType } from '../../api/schema-types';
import styles from './ProcessorTree.module.css'; import styles from './ProcessorTree.module.css';
const ICON_MAP: Record<string, { label: string; className: string }> = { const ICON_MAP: Record<string, { label: string; className: string }> = {
@@ -35,7 +35,7 @@ export function ProcessorTree({ executionId }: { executionId: string }) {
return ( return (
<div className={styles.tree}> <div className={styles.tree}>
<h4 className={styles.title}>Processor Execution Tree</h4> <h4 className={styles.title}>Processor Execution Tree</h4>
{data.processors.map((proc, i) => ( {(data.processors as ProcessorNodeType[])?.map((proc, i) => (
<ProcessorNodeView key={proc.processorId ?? i} node={proc} /> <ProcessorNodeView key={proc.processorId ?? i} node={proc} />
))} ))}
</div> </div>
@@ -57,7 +57,7 @@ function ProcessorNodeView({ node }: { node: ProcessorNodeType }) {
<span className={styles.procDuration}>{node.durationMs}ms</span> <span className={styles.procDuration}>{node.durationMs}ms</span>
</div> </div>
</div> </div>
{node.children.length > 0 && ( {node.children && node.children.length > 0 && (
<div className={styles.nested}> <div className={styles.nested}>
{node.children.map((child, i) => ( {node.children.map((child, i) => (
<ProcessorNodeView key={child.processorId ?? i} node={child} /> <ProcessorNodeView key={child.processorId ?? i} node={child} />

View File

@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import type { ExecutionSummary } from '../../api/schema'; import type { ExecutionSummary } from '../../api/schema-types';
import { StatusPill } from '../../components/shared/StatusPill'; import { StatusPill } from '../../components/shared/StatusPill';
import { DurationBar } from '../../components/shared/DurationBar'; import { DurationBar } from '../../components/shared/DurationBar';
import { AppBadge } from '../../components/shared/AppBadge'; import { AppBadge } from '../../components/shared/AppBadge';

View File

@@ -7,7 +7,7 @@ import { ScopeTabs } from '../../components/command-palette/ScopeTabs';
import { ResultsList } from '../../components/command-palette/ResultsList'; import { ResultsList } from '../../components/command-palette/ResultsList';
import { PaletteFooter } from '../../components/command-palette/PaletteFooter'; import { PaletteFooter } from '../../components/command-palette/PaletteFooter';
import { FilterChip } from '../../components/shared/FilterChip'; import { FilterChip } from '../../components/shared/FilterChip';
import type { ExecutionSummary, AgentInstance } from '../../api/schema'; import type { ExecutionSummary, AgentInstance } from '../../api/schema-types';
import styles from './SearchFilters.module.css'; import styles from './SearchFilters.module.css';
export function SearchFilters() { export function SearchFilters() {

View File

@@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { SearchRequest } from '../../api/schema'; import type { SearchRequest } from '../../api/schema-types';
function todayMidnight(): string { function todayMidnight(): string {
const d = new Date(); const d = new Date();
@@ -94,8 +94,8 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
status: statusStr ?? undefined, status: statusStr ?? undefined,
timeFrom: s.timeFrom ? new Date(s.timeFrom).toISOString() : undefined, timeFrom: s.timeFrom ? new Date(s.timeFrom).toISOString() : undefined,
timeTo: s.timeTo ? new Date(s.timeTo).toISOString() : undefined, timeTo: s.timeTo ? new Date(s.timeTo).toISOString() : undefined,
durationMin: s.durationMin, durationMin: s.durationMin ?? undefined,
durationMax: s.durationMax, durationMax: s.durationMax ?? undefined,
text: s.text || undefined, text: s.text || undefined,
routeId: s.routeId || undefined, routeId: s.routeId || undefined,
agentId: s.agentId || undefined, agentId: s.agentId || undefined,