This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch custos-signer
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
The following commit(s) were added to refs/heads/custos-signer by this push:
new 8908fa7ef OIDC token validation and tests
8908fa7ef is described below
commit 8908fa7efdf7ee90d5ade2d48ec7ff30314a5f87
Author: lahiruj <[email protected]>
AuthorDate: Tue Dec 16 23:11:35 2025 -0500
OIDC token validation and tests
---
pom.xml | 6 +
signer/signer-sdk-core/pom.xml | 6 +
.../signer/sdk/util/TestJwtTokenGenerator.java | 182 ++++++++++++++++++++-
.../src/test/resources/test-oidc.properties | 16 ++
signer/signer-service/pom.xml | 22 ++-
.../custos/signer/service/auth/JwksResolver.java | 135 +++++++++++++++
.../signer/service/auth/OidcProviderConfig.java | 114 +++++++++++++
.../service/auth/OidcProviderConfigResolver.java | 99 +++++++++++
.../signer/service/auth/OidcTokenValidator.java | 177 +++++++++++++++-----
.../custos/signer/service/config/CacheConfig.java | 55 +++++++
.../signer/service/grpc/SshSignerGrpcService.java | 2 +-
.../src/main/resources/application.yml | 9 +-
12 files changed, 770 insertions(+), 53 deletions(-)
diff --git a/pom.xml b/pom.xml
index 096794357..946030d36 100644
--- a/pom.xml
+++ b/pom.xml
@@ -160,6 +160,11 @@
<artifactId>nimbus-jose-jwt</artifactId>
<version>${nimbusds.jwt.version}</version>
</dependency>
+ <dependency>
+ <groupId>com.squareup.okhttp3</groupId>
+ <artifactId>okhttp</artifactId>
+ <version>${okhttp.version}</version>
+ </dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
@@ -280,6 +285,7 @@
<testng.version>6.8</testng.version>
<org.json.version>20240303</org.json.version>
<nimbusds.jwt.version>9.40</nimbusds.jwt.version>
+ <okhttp.version>4.12.0</okhttp.version>
<bouncycastle.version>1.78</bouncycastle.version>
<testcontainers.version>1.19.3</testcontainers.version>
<sshj.version>0.38.0</sshj.version>
diff --git a/signer/signer-sdk-core/pom.xml b/signer/signer-sdk-core/pom.xml
index adac60b1e..35f63d7f6 100644
--- a/signer/signer-sdk-core/pom.xml
+++ b/signer/signer-sdk-core/pom.xml
@@ -99,6 +99,12 @@
<artifactId>nimbus-jose-jwt</artifactId>
<scope>test</scope>
</dependency>
+ <!-- HTTP Client for OIDC token fetching -->
+ <dependency>
+ <groupId>com.squareup.okhttp3</groupId>
+ <artifactId>okhttp</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/TestJwtTokenGenerator.java
b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/TestJwtTokenGenerator.java
index 2a9a87421..dd0cc6614 100644
---
a/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/TestJwtTokenGenerator.java
+++
b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/TestJwtTokenGenerator.java
@@ -17,6 +17,8 @@
*/
package org.apache.custos.signer.sdk.util;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
@@ -26,25 +28,61 @@ import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
+import okhttp3.FormBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.io.IOException;
+import java.io.InputStream;
import java.time.Instant;
import java.util.Date;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
/**
- * Utility class for generating test JWT tokens for integration tests.
+ * Utility class for fetching real OIDC tokens or generating test JWT tokens
for integration tests.
*/
public class TestJwtTokenGenerator {
- // Shared secret for signing (for testing only)
+ private static final Logger LOGGER =
LoggerFactory.getLogger(TestJwtTokenGenerator.class);
+
+ // Test shared secret for signing
private static final String TEST_SECRET =
"test-secret-key-for-jwt-signing-in-integration-tests-only";
+ private static OidcTestConfig oidcConfig = null;
+ private static final Object configLock = new Object();
+
+ private static final OkHttpClient httpClient = new OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build();
+
/**
- * Generate a test JWT token with the specified principal.
+ * If OIDC test configuration is available, fetches a real token from OIDC
provider.
+ * Otherwise, generates a test token.
*
* @param principal The principal (username) to include in the token
* @return A signed JWT token string
*/
public static String generateTestToken(String principal) {
+ OidcTestConfig config = getOidcTestConfig();
+
+ if (config != null && config.isValid()) {
+ LOGGER.info("OIDC test configuration found, fetching real token
from: {}", config.tokenUrl);
+ try {
+ return fetchOidcToken(config, principal);
+ } catch (Exception e) {
+ LOGGER.warn("Failed to fetch OIDC token, falling back to test
token generation: {}", e.getMessage());
+ // Proceed to test token generation
+ }
+ }
+
+ // Fallback to test token generation
+ LOGGER.debug("Using test token generation (no OIDC config or fetch
failed)");
return generateTestToken(principal, "custos-test-issuer", 3600);
}
@@ -85,6 +123,109 @@ public class TestJwtTokenGenerator {
}
}
+ /**
+ * Fetch a real OIDC token using Resource Owner Password Credentials grant.
+ */
+ private static String fetchOidcToken(OidcTestConfig config, String
principal) throws IOException {
+ RequestBody formBody = new FormBody.Builder()
+ .add("grant_type", "password")
+ .add("client_id", config.clientId)
+ .add("username", config.username)
+ .add("password", config.password)
+ .add("scope", "openid profile email")
+ .build();
+
+ Request request = new Request.Builder()
+ .url(config.tokenUrl)
+ .post(formBody)
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .build();
+
+ try (Response response = httpClient.newCall(request).execute()) {
+ if (!response.isSuccessful()) {
+ String errorBody = response.body() != null ?
response.body().string() : "No error body";
+ throw new IOException("OIDC token request failed: HTTP " +
response.code() + " - " + errorBody);
+ }
+
+ String responseBody = response.body() != null ?
response.body().string() : null;
+ if (responseBody == null) {
+ throw new IOException("Empty response from OIDC token
endpoint");
+ }
+
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode json = mapper.readTree(responseBody);
+
+ String token = json.path("access_token").asText(null);
+ if (token == null || token.isEmpty()) {
+ token = json.path("id_token").asText(null);
+ }
+
+ if (token == null || token.isEmpty()) {
+ throw new IOException("No token found in OIDC response");
+ }
+
+ LOGGER.info("Successfully fetched OIDC token for user: {}",
config.username);
+ return token;
+
+ } catch (Exception e) {
+ LOGGER.error("Error fetching OIDC token", e);
+ throw new IOException("Failed to fetch OIDC token: " +
e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Load OIDC test configuration from test-oidc.properties.
+ */
+ private static OidcTestConfig getOidcTestConfig() {
+ if (oidcConfig != null) {
+ return oidcConfig;
+ }
+
+ synchronized (configLock) {
+ if (oidcConfig != null) {
+ return oidcConfig;
+ }
+
+ try {
+ InputStream is =
TestJwtTokenGenerator.class.getClassLoader().getResourceAsStream("test-oidc.properties");
+
+ if (is == null) {
+ LOGGER.debug("test-oidc.properties not found, will use
test token generation");
+ oidcConfig = new OidcTestConfig();
+ return oidcConfig;
+ }
+
+ Properties props = new Properties();
+ props.load(is);
+ is.close();
+
+ String tokenUrl = props.getProperty("test.oidc.token.url");
+ String clientId = props.getProperty("test.oidc.client.id");
+ String username = props.getProperty("test.oidc.username");
+ String password = props.getProperty("test.oidc.password");
+ String issuer = props.getProperty("test.oidc.issuer");
+
+ if (tokenUrl != null && !tokenUrl.trim().isEmpty() &&
+ clientId != null && !clientId.trim().isEmpty() &&
+ username != null && !username.trim().isEmpty() &&
+ password != null && !password.trim().isEmpty()) {
+
+ oidcConfig = new OidcTestConfig(tokenUrl, clientId,
username, password, issuer);
+ LOGGER.info("Loaded OIDC test configuration: tokenUrl={},
clientId={}, username={}", tokenUrl, clientId, username);
+ } else {
+ LOGGER.debug("OIDC test configuration incomplete, will use
test token generation");
+ oidcConfig = new OidcTestConfig();
+ }
+
+ } catch (Exception e) {
+ LOGGER.warn("Failed to load test-oidc.properties: {}",
e.getMessage());
+ oidcConfig = new OidcTestConfig();
+ }
+
+ return oidcConfig;
+ }
+ }
+
/**
* Verify a test JWT token (for testing the generator itself).
*
@@ -100,5 +241,38 @@ public class TestJwtTokenGenerator {
return false;
}
}
-}
+ /**
+ * Container for OIDC test configuration.
+ */
+ private static class OidcTestConfig {
+ final String tokenUrl;
+ final String clientId;
+ final String username;
+ final String password;
+ final String issuer;
+
+ OidcTestConfig() {
+ this.tokenUrl = null;
+ this.clientId = null;
+ this.username = null;
+ this.password = null;
+ this.issuer = null;
+ }
+
+ OidcTestConfig(String tokenUrl, String clientId, String username,
String password, String issuer) {
+ this.tokenUrl = tokenUrl;
+ this.clientId = clientId;
+ this.username = username;
+ this.password = password;
+ this.issuer = issuer;
+ }
+
+ boolean isValid() {
+ return tokenUrl != null && !tokenUrl.trim().isEmpty() &&
+ clientId != null && !clientId.trim().isEmpty() &&
+ username != null && !username.trim().isEmpty() &&
+ password != null && !password.trim().isEmpty();
+ }
+ }
+}
diff --git a/signer/signer-sdk-core/src/test/resources/test-oidc.properties
b/signer/signer-sdk-core/src/test/resources/test-oidc.properties
new file mode 100644
index 000000000..966cc34a0
--- /dev/null
+++ b/signer/signer-sdk-core/src/test/resources/test-oidc.properties
@@ -0,0 +1,16 @@
+# OIDC Test Configuration
+# This file contains test credentials for fetching real OIDC tokens during
integration tests.
+# If this file is not present or properties are missing, tests will fall back
to generating test tokens.
+
+# OIDC Token Endpoint
+test.oidc.token.url=https://auth.dev.cybershuttle.org/realms/default/protocol/openid-connect/token
+
+# OIDC Client Configuration (public client, no secret)
+test.oidc.client.id=custos
+
+# Test User Credentials
+test.oidc.username=custos
+test.oidc.password=CHANGE_ME
+
+# OIDC Issuer (for validation)
+test.oidc.issuer=https://auth.dev.cybershuttle.org/realms/default
diff --git a/signer/signer-service/pom.xml b/signer/signer-service/pom.xml
index 4ec113223..823533b6f 100644
--- a/signer/signer-service/pom.xml
+++ b/signer/signer-service/pom.xml
@@ -63,6 +63,16 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
+ <!-- Spring Cache -->
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-cache</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.github.ben-manes.caffeine</groupId>
+ <artifactId>caffeine</artifactId>
+ </dependency>
+
<!-- Vault/OpenBao -->
<dependency>
<groupId>org.springframework.vault</groupId>
@@ -127,6 +137,12 @@
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
+ <!-- HTTP Client for JWKS fetching -->
+ <dependency>
+ <groupId>com.squareup.okhttp3</groupId>
+ <artifactId>okhttp</artifactId>
+ </dependency>
+
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.security</groupId>
@@ -194,9 +210,11 @@
<artifactId>protobuf-maven-plugin</artifactId>
<version>${protobuf.maven.plugin}</version>
<configuration>
-
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
+
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}
+ </protocArtifact>
<pluginId>grpc-java</pluginId>
-
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${io.grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
+
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${io.grpc.version}:exe:${os.detected.classifier}
+ </pluginArtifact>
</configuration>
<executions>
<execution>
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/JwksResolver.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/JwksResolver.java
new file mode 100644
index 000000000..5e86463a4
--- /dev/null
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/JwksResolver.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the specific language
+ * governing permissions and limitations under the License.
+ *
+ */
+package org.apache.custos.signer.service.auth;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jose.util.DefaultResourceRetriever;
+import com.nimbusds.jose.util.Resource;
+import com.nimbusds.jose.util.ResourceRetriever;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Component;
+
+import java.net.URL;
+import java.util.concurrent.TimeUnit;
+
+import static org.apache.custos.signer.service.config.CacheConfig.JWKS_CACHE;
+
+/**
+ * Resolves and caches JWKS for OIDC providers.
+ * Provides JWKSource for token signature verification.
+ */
+@Component
+public class JwksResolver {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(JwksResolver.class);
+
+ private final OkHttpClient httpClient;
+
+ public JwksResolver() {
+ this.httpClient = new OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build();
+ }
+
+ /**
+ * Get JWKSource for a given issuer.
+ * JWKS is cached and keyed by issuer.
+ */
+ @Cacheable(cacheNames = JWKS_CACHE, key = "#root.args[0].issuer")
+ public JWKSource<SecurityContext> getJwkSource(OidcProviderConfig
providerConfig) {
+ return new ImmutableJWKSet<>(fetchJwkSet(providerConfig));
+ }
+
+ /**
+ * Discover JWKS URI from OIDC discovery endpoint.
+ * Falls back to provider config if discovery fails.
+ */
+ public String discoverJwksUri(String issuer) {
+ try {
+ String discoveryUrl = (issuer.endsWith("/") ? issuer : issuer +
"/") + ".well-known/openid-configuration";
+ LOGGER.debug("Discovering JWKS URI from: {}", discoveryUrl);
+
+ Request request = new Request.Builder()
+ .url(discoveryUrl)
+ .get()
+ .build();
+
+ try (Response response = httpClient.newCall(request).execute()) {
+ if (!response.isSuccessful()) {
+ LOGGER.warn("Failed to fetch OIDC discovery document: HTTP
{}", response.code());
+ return null;
+ }
+
+ String body = response.body() != null ?
response.body().string() : null;
+ if (body == null) {
+ return null;
+ }
+
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode json = mapper.readTree(body);
+
+ String jwksUri = json.path("jwks_uri").asText(null);
+ if (jwksUri != null && !jwksUri.isEmpty()) {
+ LOGGER.debug("Discovered JWKS URI: {}", jwksUri);
+ return jwksUri;
+ }
+
+ LOGGER.warn("JWKS URI not found in discovery document");
+ return null;
+ }
+
+ } catch (Exception e) {
+ LOGGER.warn("Failed to discover JWKS URI from issuer: {}", issuer,
e);
+ return null;
+ }
+ }
+
+ private JWKSet fetchJwkSet(OidcProviderConfig providerConfig) {
+ try {
+ URL jwksUrl = new URL(providerConfig.getJwksUri());
+
+ ResourceRetriever resourceRetriever = new
DefaultResourceRetriever(providerConfig.getTimeoutSeconds() * 1000,
providerConfig.getTimeoutSeconds() * 1000, 64 * 1024);
+
+ LOGGER.debug("Fetching JWKS JSON from: {}", jwksUrl);
+ Resource resource = resourceRetriever.retrieveResource(jwksUrl);
+ if (resource == null || resource.getContent() == null ||
resource.getContent().isBlank()) {
+ throw new RuntimeException("Empty JWKS response from " +
jwksUrl);
+ }
+
+ JWKSet jwkSet = JWKSet.parse(resource.getContent());
+ LOGGER.info("Successfully fetched and parsed JWKS for issuer: {}",
providerConfig.getIssuer());
+ return jwkSet;
+
+ } catch (Exception e) {
+ LOGGER.error("Failed to fetch/parse JWKS for issuer: {} from: {}",
providerConfig.getIssuer(), providerConfig.getJwksUri(), e);
+ throw new RuntimeException("Failed to fetch/parse JWKS: " +
e.getMessage(), e);
+ }
+ }
+}
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcProviderConfig.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcProviderConfig.java
new file mode 100644
index 000000000..3ce9ff55d
--- /dev/null
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcProviderConfig.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the specific language
+ * governing permissions and limitations under the License.
+ *
+ */
+package org.apache.custos.signer.service.auth;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * Configuration for an OIDC provider.
+ * This class holds provider-specific configuration that can be loaded from
+ * application.yml (current) or from tenant-specific storage (future) TODO.
+ */
+@Component
+@ConfigurationProperties(prefix = "signer.auth.oidc.provider")
+public class OidcProviderConfig {
+ private String issuer;
+ private String jwksUri;
+ private String clientId;
+ private int timeoutSeconds = 10;
+ private boolean verifySsl = true;
+
+ public OidcProviderConfig() {
+ }
+
+ public OidcProviderConfig(String issuer, String jwksUri, String clientId,
int timeoutSeconds, boolean verifySsl) {
+ this.issuer = Objects.requireNonNull(issuer, "Issuer cannot be null");
+ this.jwksUri = Objects.requireNonNull(jwksUri, "JWKS URI cannot be
null");
+ this.clientId = clientId;
+ this.timeoutSeconds = timeoutSeconds > 0 ? timeoutSeconds : 10;
+ this.verifySsl = verifySsl;
+ }
+
+ public String getIssuer() {
+ return issuer;
+ }
+
+ public void setIssuer(String issuer) {
+ this.issuer = issuer;
+ }
+
+ public String getJwksUri() {
+ return jwksUri;
+ }
+
+ public void setJwksUri(String jwksUri) {
+ this.jwksUri = jwksUri;
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public void setClientId(String clientId) {
+ this.clientId = clientId;
+ }
+
+ public int getTimeoutSeconds() {
+ return timeoutSeconds;
+ }
+
+ public void setTimeoutSeconds(int timeoutSeconds) {
+ this.timeoutSeconds = timeoutSeconds > 0 ? timeoutSeconds : 10;
+ }
+
+ public boolean isVerifySsl() {
+ return verifySsl;
+ }
+
+ public void setVerifySsl(boolean verifySsl) {
+ this.verifySsl = verifySsl;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ OidcProviderConfig that = (OidcProviderConfig) o;
+ return Objects.equals(issuer, that.issuer);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(issuer);
+ }
+
+ @Override
+ public String toString() {
+ return "OidcProviderConfig{" +
+ "issuer='" + issuer + '\'' +
+ ", jwksUri='" + jwksUri + '\'' +
+ ", clientId='" + clientId + '\'' +
+ ", timeoutSeconds=" + timeoutSeconds +
+ ", verifySsl=" + verifySsl +
+ '}';
+ }
+}
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcProviderConfigResolver.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcProviderConfigResolver.java
new file mode 100644
index 000000000..6aa10a1bc
--- /dev/null
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcProviderConfigResolver.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+package org.apache.custos.signer.service.auth;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Component;
+
+import static
org.apache.custos.signer.service.config.CacheConfig.OIDC_PROVIDER_CONFIG_CACHE;
+
+/**
+ * Resolves an {@link OidcProviderConfig} for an issuer using configured
providers and OIDC discovery.
+ */
+@Component
+public class OidcProviderConfigResolver {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(OidcProviderConfigResolver.class);
+
+ private final JwksResolver jwksResolver;
+ private final OidcProviderConfig provider;
+
+ public OidcProviderConfigResolver(JwksResolver jwksResolver,
OidcProviderConfig provider) {
+ this.jwksResolver = jwksResolver;
+ this.provider = provider;
+ }
+
+ /**
+ * Resolve a provider config for the given issuer.
+ */
+ @Cacheable(cacheNames = OIDC_PROVIDER_CONFIG_CACHE, key = "#root.args[0]",
unless = "#result == null")
+ public OidcProviderConfig resolveProviderConfig(String issuer) {
+ if (issuer == null || issuer.isBlank()) {
+ return null;
+ }
+
+ // If a provider is configured, only accept tokens from that issuer
+ if (provider != null && provider.getIssuer() != null &&
!provider.getIssuer().isBlank()) {
+ String configuredIssuer = provider.getIssuer();
+ boolean matches = issuer.equals(configuredIssuer) ||
+ issuer.startsWith(configuredIssuer) ||
+ configuredIssuer.startsWith(issuer);
+
+ if (!matches) {
+ return null;
+ }
+
+ // If jwks-uri is configured, use it
+ if (provider.getJwksUri() != null &&
!provider.getJwksUri().isBlank()) {
+ return provider;
+ }
+
+ // Otherwise, discover jwks-uri for this issuer
+ LOGGER.debug("OIDC provider configured for issuer '{}', but
jwks-uri missing; attempting discovery", issuer);
+ String jwksUri = jwksResolver.discoverJwksUri(issuer);
+ if (jwksUri == null || jwksUri.isBlank()) {
+ return null;
+ }
+
+ return new OidcProviderConfig(
+ configuredIssuer,
+ jwksUri,
+ provider.getClientId(),
+ provider.getTimeoutSeconds(),
+ provider.isVerifySsl()
+ );
+ }
+
+ LOGGER.warn("No OIDC provider configured under
signer.auth.oidc.provider; issuer resolution will rely on discovery only.");
+ String jwksUri = jwksResolver.discoverJwksUri(issuer);
+ if (jwksUri == null || jwksUri.isBlank()) {
+ return null;
+ }
+
+ OidcProviderConfig discovered = new OidcProviderConfig(issuer,
jwksUri, null, 10, true);
+ LOGGER.info("Discovered JWKS URI for issuer: {} -> {}", issuer,
jwksUri);
+ return discovered;
+ }
+
+}
+
+
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcTokenValidator.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcTokenValidator.java
index 769993c14..98a25d590 100644
---
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcTokenValidator.java
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/OidcTokenValidator.java
@@ -18,90 +18,194 @@
*/
package org.apache.custos.signer.service.auth;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.BadJOSEException;
+import com.nimbusds.jose.proc.JWSVerificationKeySelector;
+import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.JWTParser;
import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
+import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.text.ParseException;
import java.time.Instant;
import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
+
/**
- * Service for validating OIDC tokens (basic implementation for v1).
- * TODO: Integrate with Custos identity service for full validation.
+ * Service for validating OIDC tokens with full JWKS signature verification.
*/
@Service
public class OidcTokenValidator {
- private static final Logger logger =
LoggerFactory.getLogger(OidcTokenValidator.class);
+ private static final Logger LOGGER =
LoggerFactory.getLogger(OidcTokenValidator.class);
+
+ @Autowired
+ private JwksResolver jwksResolver;
- @Value("${signer.auth.allowed-issuers:}")
- private String allowedIssuers;
+ @Autowired
+ private OidcProviderConfigResolver providerConfigResolver;
@Value("${signer.auth.token-validation.enabled:true}")
private boolean tokenValidationEnabled;
/**
- * Validate OIDC token and extract user identity
+ * Validate access token with signature verification.
+ */
+ public UserIdentity validateAccessToken(String accessToken) {
+ return validateToken(accessToken, false);
+ }
+
+ /**
+ * Internal method to validate token with optional ID token specific
checks.
*/
- public UserIdentity validateToken(String token) {
+ private UserIdentity validateToken(String token, boolean isIdToken) {
if (!tokenValidationEnabled) {
- logger.debug("Token validation is disabled, returning default
identity");
+ LOGGER.debug("Token validation is disabled, returning default
identity");
return new UserIdentity("default-user", Map.of("sub",
"default-user"));
}
try {
- // Parse JWT token
JWT jwt = JWTParser.parse(token);
- if (!(jwt instanceof SignedJWT)) {
+ if (!(jwt instanceof SignedJWT signedJWT)) {
throw new TokenValidationException("Token is not a signed
JWT");
}
- SignedJWT signedJWT = (SignedJWT) jwt;
+ JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet();
+
+ String issuer = claimsSet.getIssuer();
+ if (issuer == null || issuer.trim().isEmpty()) {
+ throw new TokenValidationException("Token missing issuer
claim");
+ }
+
+ // Find provider config for this issuer
+ OidcProviderConfig providerConfig =
providerConfigResolver.resolveProviderConfig(issuer);
+ if (providerConfig == null) {
+ throw new TokenValidationException("No OIDC provider
configured for issuer: " + issuer);
+ }
- // Extract claims
- Map<String, Object> claims =
signedJWT.getJWTClaimsSet().getClaims();
+ // Validate token signature using JWKS
+ validateTokenSignature(signedJWT, providerConfig);
// Validate expiry
- Date exp = signedJWT.getJWTClaimsSet().getExpirationTime();
+ Date exp = claimsSet.getExpirationTime();
if (exp != null && exp.before(Date.from(Instant.now()))) {
throw new TokenValidationException("Token has expired");
}
- // Validate issuer (if configured)
- if (allowedIssuers != null && !allowedIssuers.trim().isEmpty()) {
- String iss = signedJWT.getJWTClaimsSet().getIssuer();
- if (iss == null || !isAllowedIssuer(iss)) {
- throw new TokenValidationException("Token issuer not
allowed: " + iss);
- }
+ // Validate not-before (if present)
+ Date nbf = claimsSet.getNotBeforeTime();
+ if (nbf != null && nbf.after(Date.from(Instant.now()))) {
+ throw new TokenValidationException("Token not yet valid (nbf
claim)");
}
- // Extract principal from claims (prefer sub, then
preferred_username, then email)
+ // ID token specific validations
+ if (isIdToken) {
+ validateIdTokenClaims(claimsSet, providerConfig);
+ }
+
+ Map<String, Object> claims = claimsSet.getClaims();
+
+ // Extract principal from claims
String principal = extractPrincipal(claims);
if (principal == null || principal.trim().isEmpty()) {
throw new TokenValidationException("No valid principal found
in token claims");
}
- logger.debug("Token validation successful for principal: {}",
principal);
+ LOGGER.debug("Token validation successful for principal: {},
issuer: {}", principal, issuer);
return new UserIdentity(principal, claims);
} catch (ParseException e) {
- logger.error("Failed to parse JWT token", e);
- throw new TokenValidationException("Invalid JWT token format");
+ LOGGER.error("Failed to parse JWT token", e);
+ throw new TokenValidationException("Invalid JWT token format: " +
e.getMessage());
+
+ } catch (JOSEException | BadJOSEException e) {
+ LOGGER.error("JWT signature validation failed", e);
+ throw new TokenValidationException("Token signature validation
failed: " + e.getMessage());
+
} catch (Exception e) {
- logger.error("Token validation failed", e);
+ LOGGER.error("Token validation failed", e);
throw new TokenValidationException("Token validation failed: " +
e.getMessage());
}
}
/**
- * Extract principal from token claims
+ * Validate token signature using JWKS.
+ */
+ private void validateTokenSignature(SignedJWT signedJWT,
OidcProviderConfig providerConfig)
+ throws JOSEException, BadJOSEException {
+ try {
+ // Get JWKSource for this provider
+ JWKSource<SecurityContext> jwkSource =
jwksResolver.getJwkSource(providerConfig);
+
+ // Create JWT processor
+ ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new
DefaultJWTProcessor<>();
+
+ // Set JWS key selector with supported algorithms
+ // Use JWSVerificationKeySelector with common JWS algorithms
+ Set<JWSAlgorithm> expectedJWSAlgs = new HashSet<>();
+ expectedJWSAlgs.add(JWSAlgorithm.RS256);
+ expectedJWSAlgs.add(JWSAlgorithm.RS384);
+ expectedJWSAlgs.add(JWSAlgorithm.RS512);
+ expectedJWSAlgs.add(JWSAlgorithm.ES256);
+ expectedJWSAlgs.add(JWSAlgorithm.ES384);
+ expectedJWSAlgs.add(JWSAlgorithm.ES512);
+ expectedJWSAlgs.add(JWSAlgorithm.EdDSA);
+ expectedJWSAlgs.add(JWSAlgorithm.PS256);
+ expectedJWSAlgs.add(JWSAlgorithm.PS384);
+ expectedJWSAlgs.add(JWSAlgorithm.PS512);
+
+ JWSVerificationKeySelector<SecurityContext> keySelector = new
JWSVerificationKeySelector<>(expectedJWSAlgs, jwkSource);
+ jwtProcessor.setJWSKeySelector(keySelector);
+
+ // Process and validate the token
+ jwtProcessor.process(signedJWT, null);
+
+ LOGGER.debug("Token signature validated successfully for issuer:
{}", providerConfig.getIssuer());
+
+ } catch (JOSEException | BadJOSEException e) {
+ LOGGER.error("Token signature validation failed for issuer: {}",
providerConfig.getIssuer(), e);
+ throw e;
+ }
+ }
+
+ /**
+ * Validate ID token specific claims (aud, azp, etc.)
+ */
+ private void validateIdTokenClaims(JWTClaimsSet claimsSet,
OidcProviderConfig providerConfig) {
+ // Validate audience if client ID is configured
+ if (providerConfig.getClientId() != null &&
!providerConfig.getClientId().trim().isEmpty()) {
+ List<String> audiences = claimsSet.getAudience();
+ if (audiences == null || audiences.isEmpty()) {
+ throw new TokenValidationException("ID token missing audience
claim");
+ }
+ if (!audiences.contains(providerConfig.getClientId())) {
+ throw new TokenValidationException("ID token audience does not
match configured client ID. Expected: " + providerConfig.getClientId() + ",
Found: " + audiences);
+ }
+ }
+
+ // Validate that token is an ID token (has 'sub' claim)
+ if (claimsSet.getSubject() == null ||
claimsSet.getSubject().trim().isEmpty()) {
+ throw new TokenValidationException("ID token missing subject (sub)
claim");
+ }
+ }
+
+ /**
+ * Extract principal from token claims.
*/
private String extractPrincipal(Map<String, Object> claims) {
// Try 'sub' first (standard OIDC subject identifier)
@@ -131,24 +235,6 @@ public class OidcTokenValidator {
return null;
}
- /**
- * Check if issuer is in allowed list
- */
- private boolean isAllowedIssuer(String issuer) {
- if (allowedIssuers == null || allowedIssuers.trim().isEmpty()) {
- return true; // No restriction configured
- }
-
- String[] allowedList = allowedIssuers.split(",");
- for (String allowed : allowedList) {
- if (issuer.trim().equals(allowed.trim())) {
- return true;
- }
- }
-
- return false;
- }
-
/**
* Hash token for audit logging (privacy protection)
*/
@@ -158,8 +244,9 @@ public class OidcTokenValidator {
java.security.MessageDigest digest =
java.security.MessageDigest.getInstance("SHA-256");
byte[] hash =
digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return java.util.Base64.getEncoder().encodeToString(hash);
+
} catch (Exception e) {
- logger.warn("Failed to hash token for audit", e);
+ LOGGER.warn("Failed to hash token for audit", e);
return "hash-failed";
}
}
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/config/CacheConfig.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/config/CacheConfig.java
new file mode 100644
index 000000000..80cc69125
--- /dev/null
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/config/CacheConfig.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+package org.apache.custos.signer.service.config;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.cache.caffeine.CaffeineCacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Duration;
+
+/**
+ * Signer-service cache configuration.
+ */
+@Configuration
+@EnableCaching
+public class CacheConfig {
+
+ public static final String JWKS_CACHE = "JwksCache";
+ public static final String OIDC_PROVIDER_CONFIG_CACHE =
"OidcProviderConfigCache";
+
+ @Bean
+ public CacheManager cacheManager() {
+ CaffeineCacheManager cacheManager = new
CaffeineCacheManager(JWKS_CACHE, OIDC_PROVIDER_CONFIG_CACHE);
+ cacheManager.setCaffeine(caffeineCacheBuilder());
+ return cacheManager;
+ }
+
+ Caffeine<Object, Object> caffeineCacheBuilder() {
+ return Caffeine.newBuilder()
+ .initialCapacity(100)
+ .maximumSize(10_000)
+ .expireAfterWrite(Duration.ofHours(1));
+ }
+}
+
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/grpc/SshSignerGrpcService.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/grpc/SshSignerGrpcService.java
index fffdb5e6f..52e14a218 100644
---
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/grpc/SshSignerGrpcService.java
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/grpc/SshSignerGrpcService.java
@@ -88,7 +88,7 @@ public class SshSignerGrpcService extends
SshSignerServiceGrpc.SshSignerServiceI
}
// Validate user access token
- OidcTokenValidator.UserIdentity userIdentity =
tokenValidator.validateToken(request.getUserAccessToken());
+ OidcTokenValidator.UserIdentity userIdentity =
tokenValidator.validateAccessToken(request.getUserAccessToken());
// Enforce policy
policyEnforcer.enforcePolicy(request, clientConfig);
diff --git a/signer/signer-service/src/main/resources/application.yml
b/signer/signer-service/src/main/resources/application.yml
index 350addd25..f56889f0c 100644
--- a/signer/signer-service/src/main/resources/application.yml
+++ b/signer/signer-service/src/main/resources/application.yml
@@ -69,7 +69,7 @@ signer:
overlap-hours: 2
krl:
publish-path: /var/lib/custos/krl
- refresh-interval-seconds: 300=
+ refresh-interval-seconds: 300
policy:
defaults:
max-ttl-seconds: 86400
@@ -78,6 +78,13 @@ signer:
allowed-issuers: ${ALLOWED_ISSUERS:}
token-validation:
enabled: true
+ oidc:
+ provider:
+ issuer: https://auth.dev.cybershuttle.org/realms/default
+ jwks-uri:
https://auth.dev.cybershuttle.org/realms/default/protocol/openid-connect/certs
+ client-id: custos
+ timeout-seconds: 10
+ verify-ssl: true
management:
endpoints: