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:


Reply via email to