refactor: remove PKCE from OIDC flow (confidential client)
Backend holds client_secret and does the token exchange server-side, making PKCE redundant. Removes code_verifier/code_challenge from all frontend auth paths and backend exchange method. Eliminates the source of "grant request is invalid" errors from verifier mismatches. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
1
ui/src/api/schema.d.ts
vendored
1
ui/src/api/schema.d.ts
vendored
@@ -1615,7 +1615,6 @@ export interface components {
|
||||
CallbackRequest: {
|
||||
code?: string;
|
||||
redirectUri?: string;
|
||||
codeVerifier?: string;
|
||||
};
|
||||
LoginRequest: {
|
||||
username?: string;
|
||||
|
||||
@@ -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<string> {
|
||||
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}`;
|
||||
|
||||
@@ -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 <Navigate to="/" replace />;
|
||||
|
||||
@@ -11,7 +11,7 @@ interface AuthState {
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
loginWithOidcCode: (code: string, redirectUri: string, codeVerifier?: string) => Promise<void>;
|
||||
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
|
||||
refresh: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
@@ -86,11 +86,11 @@ export const useAuthStore = create<AuthState>((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');
|
||||
|
||||
Reference in New Issue
Block a user