refactor: architecture cleanup — OIDC dedup, PKCE, K8s hardening
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m6s
CI / docker (push) Successful in 59s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Failing after 2m59s

- 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:
hsiegeln
2026-04-06 21:57:29 +02:00
parent 07ff576eb6
commit c502a42f17
19 changed files with 191 additions and 169 deletions

11
.gitea/sanitize-branch.sh Normal file
View 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/-$//'
}

View File

@@ -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 '-' '_')"

View File

@@ -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

View File

@@ -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,23 +107,20 @@ 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<String> extractRolesFromScopes(Jwt jwt) {
String scopeStr = jwt.getClaimAsString("scope");
if (scopeStr == null || scopeStr.isBlank()) {
return List.of("VIEWER");
}
List<String> 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");
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");
}
private boolean hasScope(List<String> scopes, String role) {
return scopes.stream().anyMatch(s ->
s.equalsIgnoreCase(role) || s.equalsIgnoreCase("server:" + role));
return List.of("VIEWER");
}
private List<GrantedAuthority> toAuthorities(List<String> roles) {

View File

@@ -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<UUID> 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) {}
}

View File

@@ -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();
}
}

View File

@@ -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<String, Object>) 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<String> 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<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());
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<SecurityContext> 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<JWSAlgorithm> expectedAlgs = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
JWSKeySelector<SecurityContext> keySelector =
new JWSVerificationKeySelector<>(expectedAlgs, jwkSource);
JWKSource<SecurityContext> jwkSource = OidcProviderHelper.buildJwkSource(
jwksUrl, securityProperties.isOidcTlsSkipVerify());
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource);
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(keySelector);

View File

@@ -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<SecurityContext> 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<JWSAlgorithm> algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
var keySelector = new JWSVerificationKeySelector<SecurityContext>(algorithms, jwkSource);
JWKSource<SecurityContext> jwkSource = OidcProviderHelper.buildJwkSource(
jwksUri, properties.isOidcTlsSkipVerify());
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource);
var processor = new DefaultJWTProcessor<SecurityContext>();
processor.setJWSKeySelector(keySelector);
// Accept any JWT type — Logto uses "at+jwt" (RFC 9068)

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1611,6 +1611,7 @@ export interface components {
CallbackRequest: {
code?: string;
redirectUri?: string;
codeVerifier?: string;
};
LoginRequest: {
username?: string;

View File

@@ -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<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",
@@ -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 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}`;
});
}
}, [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}`;
};

View File

@@ -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 <Navigate to="/" replace />;

View File

@@ -11,7 +11,7 @@ interface AuthState {
error: string | null;
loading: boolean;
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>;
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 });
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');

View File

@@ -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) => ({

View File

@@ -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 <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(/\/$/, '') || '';
const basename = config.basePath.replace(/\/$/, '') || undefined;
export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
@@ -81,14 +68,6 @@ export const router = createBrowserRouter([
{ path: 'config', 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)
{
element: <RequireAdmin />,
@@ -110,4 +89,4 @@ export const router = createBrowserRouter([
},
],
},
], { basename: basename || undefined });
], { basename });