diff --git a/.gitea/sanitize-branch.sh b/.gitea/sanitize-branch.sh new file mode 100644 index 00000000..73cb5fe5 --- /dev/null +++ b/.gitea/sanitize-branch.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Shared branch slug sanitization for CI jobs. +# Strips prefix (feature/, fix/, etc.), lowercases, replaces non-alphanum, truncates to 20 chars. +sanitize_branch() { + echo "$1" | sed -E 's#^(feature|fix|feat|hotfix)/##' \ + | tr '[:upper:]' '[:lower:]' \ + | sed 's/[^a-z0-9-]/-/g' \ + | sed 's/--*/-/g; s/^-//; s/-$//' \ + | cut -c1-20 \ + | sed 's/-$//' +} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4125faf0..c612c271 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -79,14 +79,7 @@ jobs: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - name: Compute branch slug run: | - sanitize_branch() { - echo "$1" | sed -E 's#^(feature|fix|feat|hotfix)/##' \ - | tr '[:upper:]' '[:lower:]' \ - | sed 's/[^a-z0-9-]/-/g' \ - | sed 's/--*/-/g; s/^-//; s/-$//' \ - | cut -c1-20 \ - | sed 's/-$//' - } + . .gitea/sanitize-branch.sh if [ "$GITHUB_REF_NAME" = "main" ]; then echo "BRANCH_SLUG=main" >> "$GITHUB_ENV" echo "IMAGE_TAGS=latest" >> "$GITHUB_ENV" @@ -277,14 +270,7 @@ jobs: KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }} - name: Compute branch variables run: | - sanitize_branch() { - echo "$1" | sed -E 's#^(feature|fix|feat|hotfix)/##' \ - | tr '[:upper:]' '[:lower:]' \ - | sed 's/[^a-z0-9-]/-/g' \ - | sed 's/--*/-/g; s/^-//; s/-$//' \ - | cut -c1-20 \ - | sed 's/-$//' - } + . .gitea/sanitize-branch.sh SLUG=$(sanitize_branch "$GITHUB_REF_NAME") NS="cam-${SLUG}" SCHEMA="cam_$(echo $SLUG | tr '-' '_')" diff --git a/Dockerfile b/Dockerfile index ee27cbce..36a271c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,10 +18,6 @@ FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=build /build/cameleer3-server-app/target/cameleer3-server-app-*.jar /app/server.jar -ENV SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/cameleer3 -ENV SPRING_DATASOURCE_USERNAME=cameleer -ENV SPRING_DATASOURCE_PASSWORD=cameleer_dev - EXPOSE 8081 ENV TZ=UTC ENTRYPOINT exec java -Duser.timezone=UTC -jar /app/server.jar diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java index f88fe755..a97f760a 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java @@ -1,6 +1,7 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.agent.AgentRegistryService; +import com.cameleer3.server.core.rbac.SystemRole; import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.JwtService.JwtValidationResult; import jakarta.servlet.FilterChain; @@ -106,25 +107,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { /** * Maps OAuth2 scopes to server RBAC roles. * Accepts both prefixed ({@code server:admin}) and bare ({@code admin}) scope names, - * case-insensitive. Scopes are defined on the Logto API Resource for this server. + * case-insensitive. Uses {@link SystemRole#normalizeScope} for consistent normalization. */ private List extractRolesFromScopes(Jwt jwt) { String scopeStr = jwt.getClaimAsString("scope"); if (scopeStr == null || scopeStr.isBlank()) { return List.of("VIEWER"); } - List scopes = List.of(scopeStr.split(" ")); - if (hasScope(scopes, "admin")) return List.of("ADMIN"); - if (hasScope(scopes, "operator")) return List.of("OPERATOR"); - if (hasScope(scopes, "viewer")) return List.of("VIEWER"); + for (String scope : scopeStr.split(" ")) { + String normalized = SystemRole.normalizeScope(scope); + if ("ADMIN".equals(normalized)) return List.of("ADMIN"); + if ("OPERATOR".equals(normalized)) return List.of("OPERATOR"); + if ("VIEWER".equals(normalized)) return List.of("VIEWER"); + } return List.of("VIEWER"); } - private boolean hasScope(List scopes, String role) { - return scopes.stream().anyMatch(s -> - s.equalsIgnoreCase(role) || s.equalsIgnoreCase("server:" + role)); - } - private List toAuthorities(List roles) { return roles.stream() .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role)) 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 9cb0ca95..bb826a11 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 @@ -127,7 +127,7 @@ public class OidcAuthController { try { OidcTokenExchanger.OidcUserInfo oidcUser = - tokenExchanger.exchange(request.code(), request.redirectUri()); + tokenExchanger.exchange(request.code(), request.redirectUri(), request.codeVerifier()); String userId = "user:oidc:" + oidcUser.subject(); String issuerHost = URI.create(config.get().issuerUri()).getHost(); @@ -177,11 +177,7 @@ public class OidcAuthController { // Resolve desired role IDs from OIDC scopes Set desired = new HashSet<>(); for (String roleName : roleNames) { - String normalized = roleName.toUpperCase(); - if (normalized.startsWith("SERVER:")) { - normalized = normalized.substring("SERVER:".length()); - } - UUID roleId = SystemRole.BY_NAME.get(normalized); + UUID roleId = SystemRole.BY_NAME.get(SystemRole.normalizeScope(roleName)); if (roleId != null) { desired.add(roleId); } @@ -207,5 +203,5 @@ public class OidcAuthController { } } - public record CallbackRequest(String code, String redirectUri) {} + public record CallbackRequest(String code, String redirectUri, String codeVerifier) {} } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcProviderHelper.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcProviderHelper.java new file mode 100644 index 00000000..f912301f --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcProviderHelper.java @@ -0,0 +1,59 @@ +package com.cameleer3.server.app.security; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.DefaultResourceRetriever; +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 java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.Set; + +/** + * Shared helpers for OIDC provider discovery and JWK source construction. + * Used by both {@link SecurityConfig} (resource server decoder) and + * {@link OidcTokenExchanger} (login token exchange). + */ +final class OidcProviderHelper { + + private static final Logger log = LoggerFactory.getLogger(OidcProviderHelper.class); + + static final Set SUPPORTED_ALGORITHMS = + Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256); + + private OidcProviderHelper() {} + + /** + * Fetches OIDC provider metadata from the well-known discovery endpoint. + */ + static OIDCProviderMetadata fetchMetadata(String issuerUri, boolean tlsSkipVerify) throws Exception { + String discoveryUrl = issuerUri.endsWith("/") + ? issuerUri + ".well-known/openid-configuration" + : issuerUri + "/.well-known/openid-configuration"; + URL url = new URI(discoveryUrl).toURL(); + try (InputStream in = InsecureTlsHelper.openStream(url, tlsSkipVerify)) { + JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in); + return OIDCProviderMetadata.parse(json); + } + } + + /** + * Builds a JWK source for the given JWKS URI, optionally skipping TLS verification. + */ + static JWKSource buildJwkSource(URL jwksUri, boolean tlsSkipVerify) throws Exception { + if (tlsSkipVerify) { + var retriever = new DefaultResourceRetriever(5000, 5000, 0, true, + InsecureTlsHelper.socketFactory()); + return new RemoteJWKSet<>(jwksUri, retriever); + } + return JWKSourceBuilder.create(jwksUri).build(); + } +} 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 ee561419..f811cfa8 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 @@ -2,10 +2,7 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfigRepository; -import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.jwk.source.JWKSourceBuilder; -import com.nimbusds.jose.proc.JWSKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.JWTClaimsSet; @@ -15,28 +12,21 @@ 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; 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 com.nimbusds.jose.jwk.source.RemoteJWKSet; -import com.nimbusds.jose.util.DefaultResourceRetriever; - -import java.io.InputStream; import java.net.URI; import java.net.URL; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Set; /** * Exchanges OIDC authorization codes for validated user information. @@ -68,7 +58,7 @@ public class OidcTokenExchanger { /** * Exchanges an authorization code for validated user info. */ - public OidcUserInfo exchange(String code, String redirectUri) throws Exception { + public OidcUserInfo exchange(String code, String redirectUri, String codeVerifier) throws Exception { OidcConfig config = getConfig(); OIDCProviderMetadata metadata = getProviderMetadata(config.issuerUri()); @@ -78,10 +68,14 @@ 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)); + TokenRequest tokenRequest = new TokenRequest( metadata.getTokenEndpointURI(), clientAuth, - new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri)) + grant ); var httpRequest = tokenRequest.toHTTPRequest(); @@ -163,7 +157,7 @@ public class OidcTokenExchanger { } @SuppressWarnings("unchecked") - private String extractStringClaim(JWTClaimsSet claims, String claimPath) { + private Object extractClaim(JWTClaimsSet claims, String claimPath) { if (claimPath == null || claimPath.isBlank()) { return null; } @@ -173,30 +167,22 @@ public class OidcTokenExchanger { for (int i = 1; i < parts.length && current instanceof Map; i++) { current = ((Map) current).get(parts[i]); } - return current instanceof String ? (String) current : null; + return current; } catch (Exception e) { - log.debug("Could not extract string from claim path '{}': {}", claimPath, e.getMessage()); + log.debug("Could not extract claim '{}': {}", claimPath, e.getMessage()); return null; } } - @SuppressWarnings("unchecked") + private String extractStringClaim(JWTClaimsSet claims, String claimPath) { + Object value = extractClaim(claims, claimPath); + return value instanceof String s ? s : null; + } + private List extractRoles(JWTClaimsSet claims, String claimPath) { - try { - String[] parts = claimPath.split("\\."); - Object current = claims.getClaim(parts[0]); - - for (int i = 1; i < parts.length && current instanceof Map; i++) { - current = ((Map) current).get(parts[i]); - } - - if (current instanceof List) { - return ((List) current).stream() - .map(Object::toString) - .toList(); - } - } catch (Exception e) { - log.debug("Could not extract roles from claim path '{}': {}", claimPath, e.getMessage()); + Object value = extractClaim(claims, claimPath); + if (value instanceof List list) { + return list.stream().map(Object::toString).toList(); } return Collections.emptyList(); } @@ -205,15 +191,8 @@ public class OidcTokenExchanger { if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) { synchronized (this) { if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) { - String discoveryPath = issuerUri.endsWith("/") - ? issuerUri + ".well-known/openid-configuration" - : issuerUri + "/.well-known/openid-configuration"; - URL discoveryUrl = new URI(discoveryPath).toURL(); - try (InputStream in = InsecureTlsHelper.openStream(discoveryUrl, securityProperties.isOidcTlsSkipVerify())) { - JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE) - .parse(in); - providerMetadata = OIDCProviderMetadata.parse(json); - } + providerMetadata = OidcProviderHelper.fetchMetadata( + issuerUri, securityProperties.isOidcTlsSkipVerify()); cachedIssuerUri = issuerUri; jwtProcessor = null; // Reset processor when issuer changes log.info("OIDC provider metadata loaded from {}", issuerUri); @@ -229,18 +208,10 @@ public class OidcTokenExchanger { if (jwtProcessor == null) { OIDCProviderMetadata metadata = getProviderMetadata(issuerUri); URL jwksUrl = metadata.getJWKSetURI().toURL(); - JWKSource jwkSource; - if (securityProperties.isOidcTlsSkipVerify()) { - var retriever = new DefaultResourceRetriever(5000, 5000, 0, true, - InsecureTlsHelper.socketFactory()); - jwkSource = new RemoteJWKSet<>(jwksUrl, retriever); - } else { - jwkSource = JWKSourceBuilder.create(jwksUrl).build(); - } - - Set expectedAlgs = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256); - JWSKeySelector keySelector = - new JWSVerificationKeySelector<>(expectedAlgs, jwkSource); + JWKSource jwkSource = OidcProviderHelper.buildJwkSource( + jwksUrl, securityProperties.isOidcTlsSkipVerify()); + var keySelector = new JWSVerificationKeySelector( + OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource); ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); processor.setJWSKeySelector(keySelector); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index 434fc057..fe27af21 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -2,17 +2,11 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.security.JwtService; -import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.jwk.source.JWKSourceBuilder; -import com.nimbusds.jose.jwk.source.RemoteJWKSet; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jose.util.DefaultResourceRetriever; import com.nimbusds.jwt.proc.DefaultJWTProcessor; 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.context.annotation.Bean; @@ -38,12 +32,10 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.io.InputStream; import java.net.URI; import java.net.URL; import java.util.Arrays; import java.util.List; -import java.util.Set; /** * Spring Security configuration for JWT-based stateless authentication with RBAC. @@ -161,37 +153,21 @@ public class SecurityConfig { String issuerUri = properties.getOidcIssuerUri(); // Resolve JWKS URI: use explicit config if set, otherwise discover from OIDC metadata. - // Explicit URI is needed when the public issuer URL isn't reachable from inside - // containers (e.g., https://domain.com/oidc) but the internal URL is (http://logto:3001/oidc/jwks). URL jwksUri; String jwkSetUri = properties.getOidcJwkSetUri(); if (jwkSetUri != null && !jwkSetUri.isBlank()) { jwksUri = new URI(jwkSetUri).toURL(); log.info("Using explicit JWKS URI: {}", jwksUri); } else { - String discoveryUrl = issuerUri.endsWith("/") - ? issuerUri + ".well-known/openid-configuration" - : issuerUri + "/.well-known/openid-configuration"; - URL url = new URI(discoveryUrl).toURL(); - OIDCProviderMetadata metadata; - try (InputStream in = InsecureTlsHelper.openStream(url, properties.isOidcTlsSkipVerify())) { - JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in); - metadata = OIDCProviderMetadata.parse(json); - } + OIDCProviderMetadata metadata = OidcProviderHelper.fetchMetadata( + issuerUri, properties.isOidcTlsSkipVerify()); jwksUri = metadata.getJWKSetURI().toURL(); } - // Build decoder supporting ES384 (Logto default) and ES256, RS256 - JWKSource jwkSource; - if (properties.isOidcTlsSkipVerify()) { - var retriever = new DefaultResourceRetriever(5000, 5000, 0, true, - InsecureTlsHelper.socketFactory()); - jwkSource = new RemoteJWKSet<>(jwksUri, retriever); - } else { - jwkSource = JWKSourceBuilder.create(jwksUri).build(); - } - Set algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256); - var keySelector = new JWSVerificationKeySelector(algorithms, jwkSource); + JWKSource jwkSource = OidcProviderHelper.buildJwkSource( + jwksUri, properties.isOidcTlsSkipVerify()); + var keySelector = new JWSVerificationKeySelector( + OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource); var processor = new DefaultJWTProcessor(); processor.setJWSKeySelector(keySelector); // Accept any JWT type — Logto uses "at+jwt" (RFC 9068) diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java index 1c176dca..f28d909a 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java @@ -20,4 +20,14 @@ public final class SystemRole { "AGENT", AGENT_ID, "VIEWER", VIEWER_ID, "OPERATOR", OPERATOR_ID, "ADMIN", ADMIN_ID); public static boolean isSystem(UUID id) { return IDS.contains(id); } + + /** + * Normalizes an OIDC scope name to a system role name. + * Strips optional {@code server:} prefix, case-insensitive. + * E.g. {@code "server:admin"} → {@code "ADMIN"}, {@code "viewer"} → {@code "VIEWER"}. + */ + public static String normalizeScope(String scope) { + String upper = scope.toUpperCase(); + return upper.startsWith("SERVER:") ? upper.substring("SERVER:".length()) : upper; + } } diff --git a/deploy/base/server.yaml b/deploy/base/server.yaml index 11ed6852..28412280 100644 --- a/deploy/base/server.yaml +++ b/deploy/base/server.yaml @@ -14,6 +14,9 @@ spec: spec: imagePullSecrets: - name: gitea-registry + securityContext: + runAsNonRoot: true + runAsUser: 1000 containers: - name: server image: gitea.siegeln.net/cameleer/cameleer3-server:latest diff --git a/deploy/base/ui.yaml b/deploy/base/ui.yaml index 12033e50..bdc1651a 100644 --- a/deploy/base/ui.yaml +++ b/deploy/base/ui.yaml @@ -24,6 +24,9 @@ spec: spec: imagePullSecrets: - name: gitea-registry + securityContext: + runAsNonRoot: true + runAsUser: 101 containers: - name: ui image: gitea.siegeln.net/cameleer/cameleer3-server-ui:latest diff --git a/deploy/clickhouse.yaml b/deploy/clickhouse.yaml index cb19e974..d8414bcb 100644 --- a/deploy/clickhouse.yaml +++ b/deploy/clickhouse.yaml @@ -14,6 +14,10 @@ spec: labels: app: clickhouse spec: + securityContext: + runAsNonRoot: true + runAsUser: 101 + fsGroup: 101 containers: - name: clickhouse image: clickhouse/clickhouse-server:24.12 diff --git a/deploy/postgres.yaml b/deploy/postgres.yaml index fc4a600f..7cf8b983 100644 --- a/deploy/postgres.yaml +++ b/deploy/postgres.yaml @@ -14,6 +14,10 @@ spec: labels: app: postgres spec: + securityContext: + runAsNonRoot: true + runAsUser: 999 + fsGroup: 999 containers: - name: postgres image: postgres:16 @@ -46,11 +50,9 @@ spec: livenessProbe: exec: command: - - pg_isready - - -U - - cameleer - - -d - - cameleer3 + - sh + - -c + - pg_isready -U "$POSTGRES_USER" -d cameleer3 initialDelaySeconds: 15 periodSeconds: 10 timeoutSeconds: 3 @@ -58,11 +60,9 @@ spec: readinessProbe: exec: command: - - pg_isready - - -U - - cameleer - - -d - - cameleer3 + - sh + - -c + - pg_isready -U "$POSTGRES_USER" -d cameleer3 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 3 diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 8ed5d579..e2dc2000 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -1611,6 +1611,7 @@ 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 9077bfbf..2644424b 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -11,6 +11,22 @@ interface OidcInfo { authorizationEndpoint: 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", @@ -68,14 +84,20 @@ export function LoginPage() { if (oidc && !forceLocal && !autoRedirected.current) { autoRedirected.current = true; const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const params = new URLSearchParams({ - response_type: 'code', - client_id: oidc.clientId, - redirect_uri: redirectUri, - scope: 'openid email profile', - prompt: 'none', + const verifier = generateCodeVerifier(); + sessionStorage.setItem('oidc-code-verifier', verifier); + deriveCodeChallenge(verifier).then((challenge) => { + const params = new URLSearchParams({ + response_type: 'code', + client_id: oidc.clientId, + redirect_uri: redirectUri, + scope: 'openid email profile', + prompt: 'none', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + window.location.href = `${oidc.authorizationEndpoint}?${params}`; }); - window.location.href = `${oidc.authorizationEndpoint}?${params}`; } }, [oidc, forceLocal]); @@ -86,15 +108,20 @@ export function LoginPage() { login(username, password); }; - const handleOidcLogin = () => { + const handleOidcLogin = async () => { 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 params = new URLSearchParams({ response_type: 'code', client_id: oidc.clientId, redirect_uri: redirectUri, scope: 'openid email profile', + code_challenge: challenge, + code_challenge_method: 'S256', }); window.location.href = `${oidc.authorizationEndpoint}?${params}`; }; diff --git a/ui/src/auth/OidcCallback.tsx b/ui/src/auth/OidcCallback.tsx index ccf72fcc..b4b6cfba 100644 --- a/ui/src/auth/OidcCallback.tsx +++ b/ui/src/auth/OidcCallback.tsx @@ -59,7 +59,9 @@ export function OidcCallback() { } const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - loginWithOidcCode(code, redirectUri); + const codeVerifier = sessionStorage.getItem('oidc-code-verifier') ?? undefined; + sessionStorage.removeItem('oidc-code-verifier'); + loginWithOidcCode(code, redirectUri, codeVerifier); }, [loginWithOidcCode]); if (isAuthenticated) return ; diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index 8f08861b..7cf318e7 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) => Promise; + loginWithOidcCode: (code: string, redirectUri: string, codeVerifier?: string) => Promise; refresh: () => Promise; logout: () => void; } @@ -86,11 +86,11 @@ export const useAuthStore = create((set, get) => ({ } }, - loginWithOidcCode: async (code, redirectUri) => { + loginWithOidcCode: async (code, redirectUri, codeVerifier?) => { set({ loading: true, error: null }); try { const { data, error } = await api.POST('/auth/oidc/callback', { - body: { code, redirectUri }, + body: { code, redirectUri, codeVerifier }, }); if (error || !data) { throw new Error('OIDC login failed'); diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index 1a78567d..d490f6ec 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -58,7 +58,7 @@ export function writeCollapsed(key: string, value: boolean): void { /** * Apps tree — one node per app, routes as children. - * Paths: /apps/{appId}, /apps/{appId}/{routeId} + * Paths: /exchanges/{appId}, /exchanges/{appId}/{routeId} */ export function buildAppTreeNodes( apps: SidebarApp[], @@ -72,7 +72,7 @@ export function buildAppTreeNodes( label: app.name, icon: statusDot(app.health), badge: formatCount(app.exchangeCount), - path: `/apps/${app.id}`, + path: `/exchanges/${app.id}`, starrable: true, starKey: `app:${app.id}`, children: app.routes.map((r) => ({ diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 45583f79..fc09fb37 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -1,9 +1,10 @@ -import { createBrowserRouter, Navigate, useParams } from 'react-router'; +import { createBrowserRouter, Navigate } from 'react-router'; import { ProtectedRoute } from './auth/ProtectedRoute'; import { RequireAdmin } from './auth/RequireAdmin'; import { LoginPage } from './auth/LoginPage'; import { OidcCallback } from './auth/OidcCallback'; import { LayoutShell } from './components/LayoutShell'; +import { config } from './config'; import { lazy, Suspense } from 'react'; import { Spinner } from '@cameleer/design-system'; @@ -28,21 +29,7 @@ function SuspenseWrapper({ children }: { children: React.ReactNode }) { ); } -/** Redirect legacy /apps/:appId/:routeId paths to /exchanges/:appId/:routeId */ -function LegacyAppRedirect() { - const { appId, routeId } = useParams<{ appId: string; routeId?: string }>(); - const path = routeId ? `/exchanges/${appId}/${routeId}` : `/exchanges/${appId}`; - return ; -} - -/** Redirect legacy /agents/:appId/:instanceId paths to /runtime/:appId/:instanceId */ -function LegacyAgentRedirect() { - const { appId, instanceId } = useParams<{ appId: string; instanceId?: string }>(); - const path = instanceId ? `/runtime/${appId}/${instanceId}` : `/runtime/${appId}`; - return ; -} - -const basename = document.querySelector('base')?.getAttribute('href')?.replace(/\/$/, '') || ''; +const basename = config.basePath.replace(/\/$/, '') || undefined; export const router = createBrowserRouter([ { path: '/login', element: }, @@ -81,14 +68,6 @@ export const router = createBrowserRouter([ { path: 'config', element: }, { path: 'config/:appId', element: }, - // Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths - { path: 'apps', element: }, - { path: 'apps/:appId', element: }, - { path: 'apps/:appId/:routeId', element: }, - { path: 'agents', element: }, - { path: 'agents/:appId', element: }, - { path: 'agents/:appId/:instanceId', element: }, - // Admin (ADMIN role required) { element: , @@ -110,4 +89,4 @@ export const router = createBrowserRouter([ }, ], }, -], { basename: basename || undefined }); +], { basename });