diff --git a/CLAUDE.md b/CLAUDE.md index 141878ef..7137506e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,8 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - Multi-tenancy: each server instance serves one tenant (configured via `CAMELEER_TENANT_ID`, default: `"default"`). Environments (dev/staging/prod) are first-class — agents send `environmentId` at registration and in heartbeats. JWT carries `env` claim for environment persistence across token refresh. PostgreSQL isolated via schema-per-tenant (`?currentSchema=tenant_{id}`). ClickHouse shared DB with `tenant_id` + `environment` columns, partitioned by `(tenant_id, toYYYYMM(timestamp))`. - Storage: PostgreSQL for RBAC, config, and audit; ClickHouse for all observability data (executions, search, logs, metrics, stats, diagrams). ClickHouse schema migrations in `clickhouse/*.sql`, run idempotently on startup by `ClickHouseSchemaInitializer`. Use `IF NOT EXISTS` for CREATE and ADD PROJECTION. - Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml -- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_CORS_ALLOWED_ORIGINS` (comma-separated) overrides `CAMELEER_UI_ORIGIN` for multi-origin setups (e.g., reverse proxy). UI role gating: Admin sidebar/routes hidden for non-ADMIN; diagram toolbar and route control hidden for VIEWER; Config is a main tab (`/config` all apps, `/config/:appId` single app with detail; sidebar clicks stay on config, route clicks resolve to parent app). Read-only for VIEWER, editable for OPERATOR+. Role helpers: `useIsAdmin()`, `useCanControl()` in `auth-store.ts`. Route guard: `RequireAdmin` in `auth/RequireAdmin.tsx`. PKCE (S256) enabled on all OIDC authorization requests. -- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Configurable `userIdClaim` (default `sub`) determines which id_token claim is used as the user identifier. Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. `CAMELEER_OIDC_JWK_SET_URI` overrides JWKS discovery for container networking. `CAMELEER_OIDC_TLS_SKIP_VERIFY=true` disables TLS cert verification for OIDC calls (self-signed CAs). Scope-based role mapping via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix): `admin`/`server:admin` → ADMIN, `operator`/`server:operator` → OPERATOR, `viewer`/`server:viewer` → VIEWER. SSO: when OIDC enabled, UI auto-redirects to provider with `prompt=none` + PKCE (S256) for silent sign-in; falls back to `/login?local` on `login_required`, retries without `prompt=none` on `consent_required`. Logout always redirects to `/login?local` (via OIDC end_session or direct fallback) to prevent SSO re-login loops. Auto-signup provisions new OIDC users with default roles. System roles synced on every OIDC login (revocations propagate on next login); group memberships are never touched. Supports ES384, ES256, RS256. Shared OIDC logic in `OidcProviderHelper` (discovery, JWK source, algorithm set). +- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_CORS_ALLOWED_ORIGINS` (comma-separated) overrides `CAMELEER_UI_ORIGIN` for multi-origin setups (e.g., reverse proxy). UI role gating: Admin sidebar/routes hidden for non-ADMIN; diagram toolbar and route control hidden for VIEWER; Config is a main tab (`/config` all apps, `/config/:appId` single app with detail; sidebar clicks stay on config, route clicks resolve to parent app). Read-only for VIEWER, editable for OPERATOR+. Role helpers: `useIsAdmin()`, `useCanControl()` in `auth-store.ts`. Route guard: `RequireAdmin` in `auth/RequireAdmin.tsx`. +- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Configurable `userIdClaim` (default `sub`) determines which id_token claim is used as the user identifier. Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. `CAMELEER_OIDC_JWK_SET_URI` overrides JWKS discovery for container networking. `CAMELEER_OIDC_TLS_SKIP_VERIFY=true` disables TLS cert verification for OIDC calls (self-signed CAs). Scope-based role mapping via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix): `admin`/`server:admin` → ADMIN, `operator`/`server:operator` → OPERATOR, `viewer`/`server:viewer` → VIEWER. SSO: when OIDC enabled, UI auto-redirects to provider with `prompt=none` for silent sign-in; falls back to `/login?local` on `login_required`, retries without `prompt=none` on `consent_required`. Logout always redirects to `/login?local` (via OIDC end_session or direct fallback) to prevent SSO re-login loops. Auto-signup provisions new OIDC users with default roles. System roles synced on every OIDC login (revocations propagate on next login); group memberships are never touched. Supports ES384, ES256, RS256. Shared OIDC logic in `OidcProviderHelper` (discovery, JWK source, algorithm set). - User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users` - Usage analytics: ClickHouse `usage_events` table tracks authenticated UI requests, flushed every 5s diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index 8c402280..5f92538a 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -129,7 +129,7 @@ public class OidcAuthController { try { OidcTokenExchanger.OidcUserInfo oidcUser = - tokenExchanger.exchange(request.code(), request.redirectUri(), request.codeVerifier()); + tokenExchanger.exchange(request.code(), request.redirectUri()); String userId = "user:oidc:" + oidcUser.subject(); String issuerHost = URI.create(config.get().issuerUri()).getHost(); @@ -219,5 +219,5 @@ public class OidcAuthController { } } - public record CallbackRequest(String code, String redirectUri, String codeVerifier) {} + public record CallbackRequest(String code, String redirectUri) {} } 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 030461b8..93d71430 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 @@ -12,7 +12,6 @@ import com.nimbusds.oauth2.sdk.AuthorizationCode; import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; import com.nimbusds.oauth2.sdk.TokenRequest; import com.nimbusds.oauth2.sdk.TokenResponse; -import com.nimbusds.oauth2.sdk.pkce.CodeVerifier; import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; import com.nimbusds.oauth2.sdk.auth.Secret; @@ -59,7 +58,7 @@ public class OidcTokenExchanger { /** * Exchanges an authorization code for validated user info. */ - public OidcUserInfo exchange(String code, String redirectUri, String codeVerifier) throws Exception { + public OidcUserInfo exchange(String code, String redirectUri) throws Exception { OidcConfig config = getConfig(); OIDCProviderMetadata metadata = getProviderMetadata(config.issuerUri()); @@ -69,9 +68,8 @@ public class OidcTokenExchanger { new Secret(config.clientSecret()) ); - AuthorizationCodeGrant grant = codeVerifier != null && !codeVerifier.isBlank() - ? new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri), new CodeVerifier(codeVerifier)) - : new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri)); + AuthorizationCodeGrant grant = new AuthorizationCodeGrant( + new AuthorizationCode(code), new URI(redirectUri)); TokenRequest tokenRequest = new TokenRequest( metadata.getTokenEndpointURI(), @@ -79,8 +77,7 @@ public class OidcTokenExchanger { grant ); - log.info("OIDC token exchange: tokenEndpoint={}, redirectUri={}, hasPkce={}, grantType={}", - metadata.getTokenEndpointURI(), redirectUri, codeVerifier != null && !codeVerifier.isBlank(), grant.getType()); + log.info("OIDC token exchange: tokenEndpoint={}, redirectUri={}", metadata.getTokenEndpointURI(), redirectUri); var httpRequest = tokenRequest.toHTTPRequest(); if (securityProperties.isOidcTlsSkipVerify()) { diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 995085d5..64eeae63 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -1615,7 +1615,6 @@ export interface components { CallbackRequest: { code?: string; redirectUri?: string; - codeVerifier?: string; }; LoginRequest: { username?: string; diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index 29a84fe6..18d2cfff 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -14,22 +14,6 @@ interface OidcInfo { additionalScopes?: string[]; } -/** Generate a random code_verifier for PKCE (RFC 7636). */ -function generateCodeVerifier(): string { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return btoa(String.fromCharCode(...array)) - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -/** Derive the S256 code_challenge from a code_verifier. */ -async function deriveCodeChallenge(verifier: string): Promise { - const data = new TextEncoder().encode(verifier); - const digest = await crypto.subtle.digest('SHA-256', data); - return btoa(String.fromCharCode(...new Uint8Array(digest))) - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - const SUBTITLES = [ "Prove you're not a mirage", "Only authorized cameleers beyond this dune", @@ -92,22 +76,16 @@ export function LoginPage() { if (oidc && !forceLocal && !autoRedirected.current) { autoRedirected.current = true; const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const verifier = generateCodeVerifier(); - sessionStorage.setItem('oidc-code-verifier', verifier); - deriveCodeChallenge(verifier).then((challenge) => { - const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])]; - const params = new URLSearchParams({ - response_type: 'code', - client_id: oidc.clientId, - redirect_uri: redirectUri, - scope: scopes.join(' '), - prompt: 'none', - code_challenge: challenge, - code_challenge_method: 'S256', - }); - if (oidc.resource) params.set('resource', oidc.resource); - window.location.href = `${oidc.authorizationEndpoint}?${params}`; + const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])]; + const params = new URLSearchParams({ + response_type: 'code', + client_id: oidc.clientId, + redirect_uri: redirectUri, + scope: scopes.join(' '), + prompt: 'none', }); + if (oidc.resource) params.set('resource', oidc.resource); + window.location.href = `${oidc.authorizationEndpoint}?${params}`; } }, [oidc, forceLocal]); @@ -118,21 +96,16 @@ export function LoginPage() { login(username, password); }; - const handleOidcLogin = async () => { + const handleOidcLogin = () => { if (!oidc) return; setOidcLoading(true); const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const verifier = generateCodeVerifier(); - sessionStorage.setItem('oidc-code-verifier', verifier); - const challenge = await deriveCodeChallenge(verifier); const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])]; const params = new URLSearchParams({ response_type: 'code', client_id: oidc.clientId, redirect_uri: redirectUri, scope: scopes.join(' '), - code_challenge: challenge, - code_challenge_method: 'S256', }); if (oidc.resource) params.set('resource', oidc.resource); window.location.href = `${oidc.authorizationEndpoint}?${params}`; diff --git a/ui/src/auth/OidcCallback.tsx b/ui/src/auth/OidcCallback.tsx index 47c21ac1..a1987625 100644 --- a/ui/src/auth/OidcCallback.tsx +++ b/ui/src/auth/OidcCallback.tsx @@ -27,25 +27,15 @@ export function OidcCallback() { // consent_required — retry without prompt=none so user can grant scopes if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) { sessionStorage.setItem('oidc-consent-retry', '1'); - api.GET('/auth/oidc/config').then(async ({ data }) => { + api.GET('/auth/oidc/config').then(({ data }) => { if (data?.authorizationEndpoint && data?.clientId) { const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const array = new Uint8Array(32); - crypto.getRandomValues(array); - const verifier = btoa(String.fromCharCode(...array)) - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - sessionStorage.setItem('oidc-code-verifier', verifier); - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); - const challenge = btoa(String.fromCharCode(...new Uint8Array(digest))) - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const scopes = ['openid', 'email', 'profile', ...(data.additionalScopes || [])]; const p = new URLSearchParams({ response_type: 'code', client_id: data.clientId, redirect_uri: redirectUri, scope: scopes.join(' '), - code_challenge: challenge, - code_challenge_method: 'S256', }); if (data.resource) p.set('resource', data.resource); window.location.href = `${data.authorizationEndpoint}?${p}`; @@ -71,9 +61,7 @@ export function OidcCallback() { } const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const codeVerifier = sessionStorage.getItem('oidc-code-verifier') ?? undefined; - sessionStorage.removeItem('oidc-code-verifier'); - loginWithOidcCode(code, redirectUri, codeVerifier); + loginWithOidcCode(code, redirectUri); }, [loginWithOidcCode]); if (isAuthenticated) return ; diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index a01adec7..9c83808e 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -11,7 +11,7 @@ interface AuthState { error: string | null; loading: boolean; login: (username: string, password: string) => Promise; - loginWithOidcCode: (code: string, redirectUri: string, codeVerifier?: string) => Promise; + loginWithOidcCode: (code: string, redirectUri: string) => Promise; refresh: () => Promise; logout: () => void; } @@ -86,11 +86,11 @@ export const useAuthStore = create((set, get) => ({ } }, - loginWithOidcCode: async (code, redirectUri, codeVerifier?) => { + loginWithOidcCode: async (code, redirectUri) => { set({ loading: true, error: null }); try { const { data, error } = await api.POST('/auth/oidc/callback', { - body: { code, redirectUri, codeVerifier }, + body: { code, redirectUri }, }); if (error || !data) { throw new Error('OIDC login failed');