refactor: architecture cleanup — OIDC dedup, PKCE, K8s hardening
- Extract OidcProviderHelper for shared discovery + JWK source construction - Add SystemRole.normalizeScope() to centralize role normalization - Merge duplicate claim extraction in OidcTokenExchanger - Add PKCE (S256) to OIDC authorization flow (frontend + backend) - Add SecurityContext (runAsNonRoot) to all K8s deployments - Fix postgres probe to use $POSTGRES_USER instead of hardcoded username - Remove default credentials from Dockerfile - Extract sanitize_branch() to shared .gitea/sanitize-branch.sh - Fix sidebar to use /exchanges/ paths directly, remove legacy redirects - Centralize basePath computation in router.tsx via config module Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
.gitea/sanitize-branch.sh
Normal file
11
.gitea/sanitize-branch.sh
Normal file
@@ -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/-$//'
|
||||||
|
}
|
||||||
@@ -79,14 +79,7 @@ jobs:
|
|||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
- name: Compute branch slug
|
- name: Compute branch slug
|
||||||
run: |
|
run: |
|
||||||
sanitize_branch() {
|
. .gitea/sanitize-branch.sh
|
||||||
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/-$//'
|
|
||||||
}
|
|
||||||
if [ "$GITHUB_REF_NAME" = "main" ]; then
|
if [ "$GITHUB_REF_NAME" = "main" ]; then
|
||||||
echo "BRANCH_SLUG=main" >> "$GITHUB_ENV"
|
echo "BRANCH_SLUG=main" >> "$GITHUB_ENV"
|
||||||
echo "IMAGE_TAGS=latest" >> "$GITHUB_ENV"
|
echo "IMAGE_TAGS=latest" >> "$GITHUB_ENV"
|
||||||
@@ -277,14 +270,7 @@ jobs:
|
|||||||
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }}
|
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }}
|
||||||
- name: Compute branch variables
|
- name: Compute branch variables
|
||||||
run: |
|
run: |
|
||||||
sanitize_branch() {
|
. .gitea/sanitize-branch.sh
|
||||||
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/-$//'
|
|
||||||
}
|
|
||||||
SLUG=$(sanitize_branch "$GITHUB_REF_NAME")
|
SLUG=$(sanitize_branch "$GITHUB_REF_NAME")
|
||||||
NS="cam-${SLUG}"
|
NS="cam-${SLUG}"
|
||||||
SCHEMA="cam_$(echo $SLUG | tr '-' '_')"
|
SCHEMA="cam_$(echo $SLUG | tr '-' '_')"
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ FROM eclipse-temurin:17-jre
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /build/cameleer3-server-app/target/cameleer3-server-app-*.jar /app/server.jar
|
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
|
EXPOSE 8081
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
ENTRYPOINT exec java -Duser.timezone=UTC -jar /app/server.jar
|
ENTRYPOINT exec java -Duser.timezone=UTC -jar /app/server.jar
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cameleer3.server.app.security;
|
package com.cameleer3.server.app.security;
|
||||||
|
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
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;
|
||||||
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
@@ -106,23 +107,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
/**
|
/**
|
||||||
* Maps OAuth2 scopes to server RBAC roles.
|
* Maps OAuth2 scopes to server RBAC roles.
|
||||||
* Accepts both prefixed ({@code server:admin}) and bare ({@code admin}) scope names,
|
* 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<String> extractRolesFromScopes(Jwt jwt) {
|
private List<String> extractRolesFromScopes(Jwt jwt) {
|
||||||
String scopeStr = jwt.getClaimAsString("scope");
|
String scopeStr = jwt.getClaimAsString("scope");
|
||||||
if (scopeStr == null || scopeStr.isBlank()) {
|
if (scopeStr == null || scopeStr.isBlank()) {
|
||||||
return List.of("VIEWER");
|
return List.of("VIEWER");
|
||||||
}
|
}
|
||||||
List<String> scopes = List.of(scopeStr.split(" "));
|
for (String scope : scopeStr.split(" ")) {
|
||||||
if (hasScope(scopes, "admin")) return List.of("ADMIN");
|
String normalized = SystemRole.normalizeScope(scope);
|
||||||
if (hasScope(scopes, "operator")) return List.of("OPERATOR");
|
if ("ADMIN".equals(normalized)) return List.of("ADMIN");
|
||||||
if (hasScope(scopes, "viewer")) return List.of("VIEWER");
|
if ("OPERATOR".equals(normalized)) return List.of("OPERATOR");
|
||||||
return List.of("VIEWER");
|
if ("VIEWER".equals(normalized)) return List.of("VIEWER");
|
||||||
}
|
}
|
||||||
|
return List.of("VIEWER");
|
||||||
private boolean hasScope(List<String> scopes, String role) {
|
|
||||||
return scopes.stream().anyMatch(s ->
|
|
||||||
s.equalsIgnoreCase(role) || s.equalsIgnoreCase("server:" + role));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<GrantedAuthority> toAuthorities(List<String> roles) {
|
private List<GrantedAuthority> toAuthorities(List<String> roles) {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ public class OidcAuthController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
OidcTokenExchanger.OidcUserInfo oidcUser =
|
OidcTokenExchanger.OidcUserInfo oidcUser =
|
||||||
tokenExchanger.exchange(request.code(), request.redirectUri());
|
tokenExchanger.exchange(request.code(), request.redirectUri(), request.codeVerifier());
|
||||||
|
|
||||||
String userId = "user:oidc:" + oidcUser.subject();
|
String userId = "user:oidc:" + oidcUser.subject();
|
||||||
String issuerHost = URI.create(config.get().issuerUri()).getHost();
|
String issuerHost = URI.create(config.get().issuerUri()).getHost();
|
||||||
@@ -177,11 +177,7 @@ public class OidcAuthController {
|
|||||||
// Resolve desired role IDs from OIDC scopes
|
// Resolve desired role IDs from OIDC scopes
|
||||||
Set<UUID> desired = new HashSet<>();
|
Set<UUID> desired = new HashSet<>();
|
||||||
for (String roleName : roleNames) {
|
for (String roleName : roleNames) {
|
||||||
String normalized = roleName.toUpperCase();
|
UUID roleId = SystemRole.BY_NAME.get(SystemRole.normalizeScope(roleName));
|
||||||
if (normalized.startsWith("SERVER:")) {
|
|
||||||
normalized = normalized.substring("SERVER:".length());
|
|
||||||
}
|
|
||||||
UUID roleId = SystemRole.BY_NAME.get(normalized);
|
|
||||||
if (roleId != null) {
|
if (roleId != null) {
|
||||||
desired.add(roleId);
|
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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<JWSAlgorithm> 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<SecurityContext> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,7 @@ package com.cameleer3.server.app.security;
|
|||||||
|
|
||||||
import com.cameleer3.server.core.security.OidcConfig;
|
import com.cameleer3.server.core.security.OidcConfig;
|
||||||
import com.cameleer3.server.core.security.OidcConfigRepository;
|
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.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.JWSVerificationKeySelector;
|
||||||
import com.nimbusds.jose.proc.SecurityContext;
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
import com.nimbusds.jwt.JWTClaimsSet;
|
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.AuthorizationCodeGrant;
|
||||||
import com.nimbusds.oauth2.sdk.TokenRequest;
|
import com.nimbusds.oauth2.sdk.TokenRequest;
|
||||||
import com.nimbusds.oauth2.sdk.TokenResponse;
|
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.ClientAuthentication;
|
||||||
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
|
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
|
||||||
import com.nimbusds.oauth2.sdk.auth.Secret;
|
import com.nimbusds.oauth2.sdk.auth.Secret;
|
||||||
import com.nimbusds.oauth2.sdk.id.ClientID;
|
import com.nimbusds.oauth2.sdk.id.ClientID;
|
||||||
import com.nimbusds.oauth2.sdk.id.Issuer;
|
|
||||||
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
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.URI;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges OIDC authorization codes for validated user information.
|
* Exchanges OIDC authorization codes for validated user information.
|
||||||
@@ -68,7 +58,7 @@ public class OidcTokenExchanger {
|
|||||||
/**
|
/**
|
||||||
* Exchanges an authorization code for validated user info.
|
* 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();
|
OidcConfig config = getConfig();
|
||||||
|
|
||||||
OIDCProviderMetadata metadata = getProviderMetadata(config.issuerUri());
|
OIDCProviderMetadata metadata = getProviderMetadata(config.issuerUri());
|
||||||
@@ -78,10 +68,14 @@ public class OidcTokenExchanger {
|
|||||||
new Secret(config.clientSecret())
|
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(
|
TokenRequest tokenRequest = new TokenRequest(
|
||||||
metadata.getTokenEndpointURI(),
|
metadata.getTokenEndpointURI(),
|
||||||
clientAuth,
|
clientAuth,
|
||||||
new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri))
|
grant
|
||||||
);
|
);
|
||||||
|
|
||||||
var httpRequest = tokenRequest.toHTTPRequest();
|
var httpRequest = tokenRequest.toHTTPRequest();
|
||||||
@@ -163,7 +157,7 @@ public class OidcTokenExchanger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private String extractStringClaim(JWTClaimsSet claims, String claimPath) {
|
private Object extractClaim(JWTClaimsSet claims, String claimPath) {
|
||||||
if (claimPath == null || claimPath.isBlank()) {
|
if (claimPath == null || claimPath.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -173,30 +167,22 @@ public class OidcTokenExchanger {
|
|||||||
for (int i = 1; i < parts.length && current instanceof Map; i++) {
|
for (int i = 1; i < parts.length && current instanceof Map; i++) {
|
||||||
current = ((Map<String, Object>) current).get(parts[i]);
|
current = ((Map<String, Object>) current).get(parts[i]);
|
||||||
}
|
}
|
||||||
return current instanceof String ? (String) current : null;
|
return current;
|
||||||
} catch (Exception e) {
|
} 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;
|
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<String> extractRoles(JWTClaimsSet claims, String claimPath) {
|
private List<String> extractRoles(JWTClaimsSet claims, String claimPath) {
|
||||||
try {
|
Object value = extractClaim(claims, claimPath);
|
||||||
String[] parts = claimPath.split("\\.");
|
if (value instanceof List<?> list) {
|
||||||
Object current = claims.getClaim(parts[0]);
|
return list.stream().map(Object::toString).toList();
|
||||||
|
|
||||||
for (int i = 1; i < parts.length && current instanceof Map; i++) {
|
|
||||||
current = ((Map<String, Object>) 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());
|
|
||||||
}
|
}
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
@@ -205,15 +191,8 @@ public class OidcTokenExchanger {
|
|||||||
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
|
||||||
String discoveryPath = issuerUri.endsWith("/")
|
providerMetadata = OidcProviderHelper.fetchMetadata(
|
||||||
? issuerUri + ".well-known/openid-configuration"
|
issuerUri, securityProperties.isOidcTlsSkipVerify());
|
||||||
: 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);
|
|
||||||
}
|
|
||||||
cachedIssuerUri = issuerUri;
|
cachedIssuerUri = issuerUri;
|
||||||
jwtProcessor = null; // Reset processor when issuer changes
|
jwtProcessor = null; // Reset processor when issuer changes
|
||||||
log.info("OIDC provider metadata loaded from {}", issuerUri);
|
log.info("OIDC provider metadata loaded from {}", issuerUri);
|
||||||
@@ -229,18 +208,10 @@ public class OidcTokenExchanger {
|
|||||||
if (jwtProcessor == null) {
|
if (jwtProcessor == null) {
|
||||||
OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
|
OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
|
||||||
URL jwksUrl = metadata.getJWKSetURI().toURL();
|
URL jwksUrl = metadata.getJWKSetURI().toURL();
|
||||||
JWKSource<SecurityContext> jwkSource;
|
JWKSource<SecurityContext> jwkSource = OidcProviderHelper.buildJwkSource(
|
||||||
if (securityProperties.isOidcTlsSkipVerify()) {
|
jwksUrl, securityProperties.isOidcTlsSkipVerify());
|
||||||
var retriever = new DefaultResourceRetriever(5000, 5000, 0, true,
|
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
|
||||||
InsecureTlsHelper.socketFactory());
|
OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource);
|
||||||
jwkSource = new RemoteJWKSet<>(jwksUrl, retriever);
|
|
||||||
} else {
|
|
||||||
jwkSource = JWKSourceBuilder.create(jwksUrl).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<JWSAlgorithm> expectedAlgs = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
|
|
||||||
JWSKeySelector<SecurityContext> keySelector =
|
|
||||||
new JWSVerificationKeySelector<>(expectedAlgs, jwkSource);
|
|
||||||
|
|
||||||
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
|
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
|
||||||
processor.setJWSKeySelector(keySelector);
|
processor.setJWSKeySelector(keySelector);
|
||||||
|
|||||||
@@ -2,17 +2,11 @@ package com.cameleer3.server.app.security;
|
|||||||
|
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
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.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.JWSVerificationKeySelector;
|
||||||
import com.nimbusds.jose.proc.SecurityContext;
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
import com.nimbusds.jose.util.DefaultResourceRetriever;
|
|
||||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||||
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.context.annotation.Bean;
|
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.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Security configuration for JWT-based stateless authentication with RBAC.
|
* Spring Security configuration for JWT-based stateless authentication with RBAC.
|
||||||
@@ -161,37 +153,21 @@ public class SecurityConfig {
|
|||||||
String issuerUri = properties.getOidcIssuerUri();
|
String issuerUri = properties.getOidcIssuerUri();
|
||||||
|
|
||||||
// Resolve JWKS URI: use explicit config if set, otherwise discover from OIDC metadata.
|
// 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;
|
URL jwksUri;
|
||||||
String jwkSetUri = properties.getOidcJwkSetUri();
|
String jwkSetUri = properties.getOidcJwkSetUri();
|
||||||
if (jwkSetUri != null && !jwkSetUri.isBlank()) {
|
if (jwkSetUri != null && !jwkSetUri.isBlank()) {
|
||||||
jwksUri = new URI(jwkSetUri).toURL();
|
jwksUri = new URI(jwkSetUri).toURL();
|
||||||
log.info("Using explicit JWKS URI: {}", jwksUri);
|
log.info("Using explicit JWKS URI: {}", jwksUri);
|
||||||
} else {
|
} else {
|
||||||
String discoveryUrl = issuerUri.endsWith("/")
|
OIDCProviderMetadata metadata = OidcProviderHelper.fetchMetadata(
|
||||||
? issuerUri + ".well-known/openid-configuration"
|
issuerUri, properties.isOidcTlsSkipVerify());
|
||||||
: 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);
|
|
||||||
}
|
|
||||||
jwksUri = metadata.getJWKSetURI().toURL();
|
jwksUri = metadata.getJWKSetURI().toURL();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build decoder supporting ES384 (Logto default) and ES256, RS256
|
JWKSource<SecurityContext> jwkSource = OidcProviderHelper.buildJwkSource(
|
||||||
JWKSource<SecurityContext> jwkSource;
|
jwksUri, properties.isOidcTlsSkipVerify());
|
||||||
if (properties.isOidcTlsSkipVerify()) {
|
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
|
||||||
var retriever = new DefaultResourceRetriever(5000, 5000, 0, true,
|
OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource);
|
||||||
InsecureTlsHelper.socketFactory());
|
|
||||||
jwkSource = new RemoteJWKSet<>(jwksUri, retriever);
|
|
||||||
} else {
|
|
||||||
jwkSource = JWKSourceBuilder.create(jwksUri).build();
|
|
||||||
}
|
|
||||||
Set<JWSAlgorithm> algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
|
|
||||||
var keySelector = new JWSVerificationKeySelector<SecurityContext>(algorithms, jwkSource);
|
|
||||||
var processor = new DefaultJWTProcessor<SecurityContext>();
|
var processor = new DefaultJWTProcessor<SecurityContext>();
|
||||||
processor.setJWSKeySelector(keySelector);
|
processor.setJWSKeySelector(keySelector);
|
||||||
// Accept any JWT type — Logto uses "at+jwt" (RFC 9068)
|
// Accept any JWT type — Logto uses "at+jwt" (RFC 9068)
|
||||||
|
|||||||
@@ -20,4 +20,14 @@ public final class SystemRole {
|
|||||||
"AGENT", AGENT_ID, "VIEWER", VIEWER_ID, "OPERATOR", OPERATOR_ID, "ADMIN", ADMIN_ID);
|
"AGENT", AGENT_ID, "VIEWER", VIEWER_ID, "OPERATOR", OPERATOR_ID, "ADMIN", ADMIN_ID);
|
||||||
|
|
||||||
public static boolean isSystem(UUID id) { return IDS.contains(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: gitea-registry
|
- name: gitea-registry
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
image: gitea.siegeln.net/cameleer/cameleer3-server:latest
|
image: gitea.siegeln.net/cameleer/cameleer3-server:latest
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: gitea-registry
|
- name: gitea-registry
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 101
|
||||||
containers:
|
containers:
|
||||||
- name: ui
|
- name: ui
|
||||||
image: gitea.siegeln.net/cameleer/cameleer3-server-ui:latest
|
image: gitea.siegeln.net/cameleer/cameleer3-server-ui:latest
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: clickhouse
|
app: clickhouse
|
||||||
spec:
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 101
|
||||||
|
fsGroup: 101
|
||||||
containers:
|
containers:
|
||||||
- name: clickhouse
|
- name: clickhouse
|
||||||
image: clickhouse/clickhouse-server:24.12
|
image: clickhouse/clickhouse-server:24.12
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: postgres
|
app: postgres
|
||||||
spec:
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 999
|
||||||
|
fsGroup: 999
|
||||||
containers:
|
containers:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
@@ -46,11 +50,9 @@ spec:
|
|||||||
livenessProbe:
|
livenessProbe:
|
||||||
exec:
|
exec:
|
||||||
command:
|
command:
|
||||||
- pg_isready
|
- sh
|
||||||
- -U
|
- -c
|
||||||
- cameleer
|
- pg_isready -U "$POSTGRES_USER" -d cameleer3
|
||||||
- -d
|
|
||||||
- cameleer3
|
|
||||||
initialDelaySeconds: 15
|
initialDelaySeconds: 15
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 3
|
timeoutSeconds: 3
|
||||||
@@ -58,11 +60,9 @@ spec:
|
|||||||
readinessProbe:
|
readinessProbe:
|
||||||
exec:
|
exec:
|
||||||
command:
|
command:
|
||||||
- pg_isready
|
- sh
|
||||||
- -U
|
- -c
|
||||||
- cameleer
|
- pg_isready -U "$POSTGRES_USER" -d cameleer3
|
||||||
- -d
|
|
||||||
- cameleer3
|
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
timeoutSeconds: 3
|
timeoutSeconds: 3
|
||||||
|
|||||||
1
ui/src/api/schema.d.ts
vendored
1
ui/src/api/schema.d.ts
vendored
@@ -1611,6 +1611,7 @@ export interface components {
|
|||||||
CallbackRequest: {
|
CallbackRequest: {
|
||||||
code?: string;
|
code?: string;
|
||||||
redirectUri?: string;
|
redirectUri?: string;
|
||||||
|
codeVerifier?: string;
|
||||||
};
|
};
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ interface OidcInfo {
|
|||||||
authorizationEndpoint: string;
|
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<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 = [
|
const SUBTITLES = [
|
||||||
"Prove you're not a mirage",
|
"Prove you're not a mirage",
|
||||||
"Only authorized cameleers beyond this dune",
|
"Only authorized cameleers beyond this dune",
|
||||||
@@ -68,14 +84,20 @@ export function LoginPage() {
|
|||||||
if (oidc && !forceLocal && !autoRedirected.current) {
|
if (oidc && !forceLocal && !autoRedirected.current) {
|
||||||
autoRedirected.current = true;
|
autoRedirected.current = true;
|
||||||
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
sessionStorage.setItem('oidc-code-verifier', verifier);
|
||||||
|
deriveCodeChallenge(verifier).then((challenge) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
client_id: oidc.clientId,
|
client_id: oidc.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope: 'openid email profile',
|
scope: 'openid email profile',
|
||||||
prompt: 'none',
|
prompt: 'none',
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
});
|
});
|
||||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [oidc, forceLocal]);
|
}, [oidc, forceLocal]);
|
||||||
|
|
||||||
@@ -86,15 +108,20 @@ export function LoginPage() {
|
|||||||
login(username, password);
|
login(username, password);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOidcLogin = () => {
|
const handleOidcLogin = async () => {
|
||||||
if (!oidc) return;
|
if (!oidc) return;
|
||||||
setOidcLoading(true);
|
setOidcLoading(true);
|
||||||
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
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({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
client_id: oidc.clientId,
|
client_id: oidc.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope: 'openid email profile',
|
scope: 'openid email profile',
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
});
|
});
|
||||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ export function OidcCallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
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]);
|
}, [loginWithOidcCode]);
|
||||||
|
|
||||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface AuthState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
|
loginWithOidcCode: (code: string, redirectUri: string, codeVerifier?: string) => Promise<void>;
|
||||||
refresh: () => Promise<boolean>;
|
refresh: () => Promise<boolean>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
@@ -86,11 +86,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithOidcCode: async (code, redirectUri) => {
|
loginWithOidcCode: async (code, redirectUri, codeVerifier?) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const { data, error } = await api.POST('/auth/oidc/callback', {
|
const { data, error } = await api.POST('/auth/oidc/callback', {
|
||||||
body: { code, redirectUri },
|
body: { code, redirectUri, codeVerifier },
|
||||||
});
|
});
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
throw new Error('OIDC login failed');
|
throw new Error('OIDC login failed');
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function writeCollapsed(key: string, value: boolean): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apps tree — one node per app, routes as children.
|
* Apps tree — one node per app, routes as children.
|
||||||
* Paths: /apps/{appId}, /apps/{appId}/{routeId}
|
* Paths: /exchanges/{appId}, /exchanges/{appId}/{routeId}
|
||||||
*/
|
*/
|
||||||
export function buildAppTreeNodes(
|
export function buildAppTreeNodes(
|
||||||
apps: SidebarApp[],
|
apps: SidebarApp[],
|
||||||
@@ -72,7 +72,7 @@ export function buildAppTreeNodes(
|
|||||||
label: app.name,
|
label: app.name,
|
||||||
icon: statusDot(app.health),
|
icon: statusDot(app.health),
|
||||||
badge: formatCount(app.exchangeCount),
|
badge: formatCount(app.exchangeCount),
|
||||||
path: `/apps/${app.id}`,
|
path: `/exchanges/${app.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
starKey: `app:${app.id}`,
|
starKey: `app:${app.id}`,
|
||||||
children: app.routes.map((r) => ({
|
children: app.routes.map((r) => ({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createBrowserRouter, Navigate, useParams } from 'react-router';
|
import { createBrowserRouter, Navigate } from 'react-router';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
import { RequireAdmin } from './auth/RequireAdmin';
|
import { RequireAdmin } from './auth/RequireAdmin';
|
||||||
import { LoginPage } from './auth/LoginPage';
|
import { LoginPage } from './auth/LoginPage';
|
||||||
import { OidcCallback } from './auth/OidcCallback';
|
import { OidcCallback } from './auth/OidcCallback';
|
||||||
import { LayoutShell } from './components/LayoutShell';
|
import { LayoutShell } from './components/LayoutShell';
|
||||||
|
import { config } from './config';
|
||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { Spinner } from '@cameleer/design-system';
|
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 */
|
const basename = config.basePath.replace(/\/$/, '') || undefined;
|
||||||
function LegacyAppRedirect() {
|
|
||||||
const { appId, routeId } = useParams<{ appId: string; routeId?: string }>();
|
|
||||||
const path = routeId ? `/exchanges/${appId}/${routeId}` : `/exchanges/${appId}`;
|
|
||||||
return <Navigate to={path} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 <Navigate to={path} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const basename = document.querySelector('base')?.getAttribute('href')?.replace(/\/$/, '') || '';
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
@@ -81,14 +68,6 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'config', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
{ path: 'config', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||||
{ path: 'config/:appId', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
{ path: 'config/:appId', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||||
|
|
||||||
// Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths
|
|
||||||
{ path: 'apps', element: <Navigate to="/exchanges" replace /> },
|
|
||||||
{ path: 'apps/:appId', element: <LegacyAppRedirect /> },
|
|
||||||
{ path: 'apps/:appId/:routeId', element: <LegacyAppRedirect /> },
|
|
||||||
{ path: 'agents', element: <Navigate to="/runtime" replace /> },
|
|
||||||
{ path: 'agents/:appId', element: <LegacyAgentRedirect /> },
|
|
||||||
{ path: 'agents/:appId/:instanceId', element: <LegacyAgentRedirect /> },
|
|
||||||
|
|
||||||
// Admin (ADMIN role required)
|
// Admin (ADMIN role required)
|
||||||
{
|
{
|
||||||
element: <RequireAdmin />,
|
element: <RequireAdmin />,
|
||||||
@@ -110,4 +89,4 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
], { basename: basename || undefined });
|
], { basename });
|
||||||
|
|||||||
Reference in New Issue
Block a user