This is an automated email from the ASF dual-hosted git repository.

haonan pushed a commit to branch fix_openid
in repository https://gitbox.apache.org/repos/asf/iotdb.git

commit df788e0773f6a0ac268eb680f6d619b8c2af1e85
Author: HTHou <[email protected]>
AuthorDate: Tue Mar 17 10:19:06 2026 +0800

    Validate OpenID issuer and audience claims
---
 .../conf/iotdb-system.properties.template          |   6 ++
 .../commons/auth/authorizer/OpenIdAuthorizer.java  | 101 +++++++++++++++++--
 .../apache/iotdb/commons/conf/CommonConfig.java    |   9 ++
 .../iotdb/commons/conf/CommonDescriptor.java       |   2 +
 .../auth/authorizer/OpenIdAuthorizerTest.java      | 112 +++++++++++++++++++++
 5 files changed, 221 insertions(+), 9 deletions(-)

diff --git 
a/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
 
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
index 3f1c4ab41ff..5d5cce4123f 100644
--- 
a/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
+++ 
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
@@ -1753,6 +1753,12 @@ 
authorizer_provider_class=org.apache.iotdb.commons.auth.authorizer.LocalFileAuth
 # Privilege: SECURITY
 openID_url=
 
+# If OpenIdAuthorizer is enabled, then openID_audience must contain the IoTDB 
client ID
+# or a comma-separated allowlist of accepted audiences.
+# effectiveMode: restart
+# Privilege: SECURITY
+openID_audience=
+
 # encryption provider class
 # effectiveMode: first_start
 
iotdb_server_encrypt_decrypt_provider=org.apache.iotdb.commons.security.encrypt.MessageDigestEncrypt
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/auth/authorizer/OpenIdAuthorizer.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/auth/authorizer/OpenIdAuthorizer.java
index f5f74a87b39..2ff888fa4ae 100644
--- 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/auth/authorizer/OpenIdAuthorizer.java
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/auth/authorizer/OpenIdAuthorizer.java
@@ -45,11 +45,16 @@ import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Scanner;
+import java.util.Set;
 import java.util.UUID;
+import java.util.stream.Collectors;
 
 /** Uses an OpenID Connect provider for Authorization / Authentication. */
 public class OpenIdAuthorizer extends BasicAuthorizer {
@@ -62,6 +67,8 @@ public class OpenIdAuthorizer extends BasicAuthorizer {
   private static final CommonConfig config = 
CommonDescriptor.getInstance().getConfig();
 
   private final RSAPublicKey providerKey;
+  private final String expectedIssuer;
+  private final Set<String> acceptedAudiences;
 
   /** Stores all claims to the respective user */
   private final Map<String, Claims> loggedClaims = new HashMap<>();
@@ -71,6 +78,11 @@ public class OpenIdAuthorizer extends BasicAuthorizer {
   }
 
   public OpenIdAuthorizer(JSONObject jwk) throws AuthException {
+    this(jwk, null, Collections.emptySet());
+  }
+
+  public OpenIdAuthorizer(JSONObject jwk, String expectedIssuer, Set<String> 
acceptedAudiences)
+      throws AuthException {
     super(
         new LocalFileUserManager(config.getUserFolder()),
         new LocalFileRoleManager(config.getRoleFolder()));
@@ -80,15 +92,21 @@ public class OpenIdAuthorizer extends BasicAuthorizer {
       throw new AuthException(
           TSStatusCode.INIT_AUTH_ERROR, "Unable to get OIDC Provider Key from 
JWK " + jwk, e);
     }
+    this.expectedIssuer = expectedIssuer;
+    this.acceptedAudiences = Collections.unmodifiableSet(new 
HashSet<>(acceptedAudiences));
     logger.info("Initialized with providerKey: {}", providerKey);
   }
 
   public OpenIdAuthorizer(String providerUrl)
       throws AuthException, URISyntaxException, ParseException, IOException {
-    this(getJwkFromProvider(providerUrl));
+    this(loadProviderContext(providerUrl));
   }
 
-  private static JSONObject getJwkFromProvider(String providerUrl)
+  private OpenIdAuthorizer(ProviderContext providerContext) throws 
AuthException {
+    this(providerContext.jwk, providerContext.issuer, 
providerContext.acceptedAudiences);
+  }
+
+  private static ProviderContext loadProviderContext(String providerUrl)
       throws URISyntaxException, IOException, ParseException, AuthException {
     if (providerUrl == null) {
       throw new IllegalArgumentException("OpenID Connect Provider URI must be 
given!");
@@ -99,10 +117,24 @@ public class OpenIdAuthorizer extends BasicAuthorizer {
 
     logger.debug("Using Provider Metadata: {}", providerMetadata);
 
+    Set<String> acceptedAudiences = parseAudiences(config.getOpenIdAudience());
+    if (acceptedAudiences.isEmpty()) {
+      throw new AuthException(
+          TSStatusCode.INIT_AUTH_ERROR,
+          "openID_audience must be configured when OpenIdAuthorizer is 
enabled");
+    }
+
+    String issuer =
+        providerMetadata.getIssuer() == null ? null : 
providerMetadata.getIssuer().getValue();
+    if (issuer == null || issuer.isEmpty()) {
+      throw new AuthException(
+          TSStatusCode.INIT_AUTH_ERROR, "OIDC provider metadata does not 
contain an issuer");
+    }
+
     try {
       URL url = new URI(providerMetadata.getJWKSetURI().toString()).toURL();
       logger.debug("Using url {}", url);
-      return getProviderRsaJwk(url.openStream());
+      return new ProviderContext(getProviderRsaJwk(url.openStream()), issuer, 
acceptedAudiences);
     } catch (IOException e) {
       throw new AuthException(TSStatusCode.INIT_AUTH_ERROR, "Unable to start 
the Auth", e);
     }
@@ -194,12 +226,51 @@ public class OpenIdAuthorizer extends BasicAuthorizer {
   }
 
   private Claims validateToken(String token) {
-    return Jwts.parser()
-        .clockSkewSeconds(MAX_CLOCK_SKEW_SECONDS)
-        .verifyWith(providerKey)
-        .build()
-        .parseSignedClaims(token)
-        .getPayload();
+    Claims claims =
+        Jwts.parser()
+            .clockSkewSeconds(MAX_CLOCK_SKEW_SECONDS)
+            .verifyWith(providerKey)
+            .build()
+            .parseSignedClaims(token)
+            .getPayload();
+    validateClaims(claims);
+    return claims;
+  }
+
+  private void validateClaims(Claims claims) {
+    if (expectedIssuer != null && !expectedIssuer.equals(claims.getIssuer())) {
+      throw new JwtException(
+          String.format("Unexpected issuer %s, expected %s", 
claims.getIssuer(), expectedIssuer));
+    }
+    if (!acceptedAudiences.isEmpty() && 
!hasAcceptedAudience(claims.get("aud"))) {
+      throw new JwtException(
+          String.format(
+              "Unexpected audience %s, expected one of %s", claims.get("aud"), 
acceptedAudiences));
+    }
+  }
+
+  private boolean hasAcceptedAudience(Object audienceClaim) {
+    if (audienceClaim instanceof String) {
+      return acceptedAudiences.contains(audienceClaim);
+    }
+    if (audienceClaim instanceof List<?>) {
+      for (Object audience : (List<?>) audienceClaim) {
+        if (audience instanceof String && 
acceptedAudiences.contains(audience)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private static Set<String> parseAudiences(String configuredAudiences) {
+    if (configuredAudiences == null || configuredAudiences.trim().isEmpty()) {
+      return Collections.emptySet();
+    }
+    return Arrays.stream(configuredAudiences.split(","))
+        .map(String::trim)
+        .filter(audience -> !audience.isEmpty())
+        .collect(Collectors.toCollection(HashSet::new));
   }
 
   private String getUsername(Claims claims) {
@@ -258,6 +329,18 @@ public class OpenIdAuthorizer extends BasicAuthorizer {
     return isAdmin(userName);
   }
 
+  private static class ProviderContext {
+    private final JSONObject jwk;
+    private final String issuer;
+    private final Set<String> acceptedAudiences;
+
+    private ProviderContext(JSONObject jwk, String issuer, Set<String> 
acceptedAudiences) {
+      this.jwk = jwk;
+      this.issuer = issuer;
+      this.acceptedAudiences = acceptedAudiences;
+    }
+  }
+
   @Override
   public void updateUserPassword(String userName, String newPassword) {
     throwUnsupportedOperationException();
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonConfig.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonConfig.java
index a490107ded3..760cccb973b 100644
--- 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonConfig.java
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonConfig.java
@@ -60,6 +60,7 @@ public class CommonConfig {
 
   // Open ID Secret
   private String openIdProviderUrl = "";
+  private String openIdAudience = "";
 
   // The authorizer provider class which extends BasicAuthorizer
   private String authorizerProvider =
@@ -543,6 +544,14 @@ public class CommonConfig {
     this.openIdProviderUrl = openIdProviderUrl;
   }
 
+  public String getOpenIdAudience() {
+    return openIdAudience;
+  }
+
+  public void setOpenIdAudience(String openIdAudience) {
+    this.openIdAudience = openIdAudience;
+  }
+
   public String getAuthorizerProvider() {
     return authorizerProvider;
   }
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonDescriptor.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonDescriptor.java
index 8483d1425cf..004b147938c 100644
--- 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonDescriptor.java
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonDescriptor.java
@@ -80,6 +80,8 @@ public class CommonDescriptor {
     // if using org.apache.iotdb.db.auth.authorizer.OpenIdAuthorizer, 
openID_url is needed.
     config.setOpenIdProviderUrl(
         properties.getProperty("openID_url", 
config.getOpenIdProviderUrl()).trim());
+    config.setOpenIdAudience(
+        properties.getProperty("openID_audience", 
config.getOpenIdAudience()).trim());
     config.setEncryptDecryptProvider(
         properties
             .getProperty(
diff --git 
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/auth/authorizer/OpenIdAuthorizerTest.java
 
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/auth/authorizer/OpenIdAuthorizerTest.java
index f41645998fe..b0f2b797f35 100644
--- 
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/auth/authorizer/OpenIdAuthorizerTest.java
+++ 
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/auth/authorizer/OpenIdAuthorizerTest.java
@@ -24,6 +24,8 @@ import org.apache.iotdb.commons.conf.CommonDescriptor;
 
 import com.nimbusds.jose.jwk.KeyUse;
 import com.nimbusds.jose.jwk.RSAKey;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
 import io.jsonwebtoken.Jwts;
 import net.minidev.json.JSONObject;
 import org.junit.After;
@@ -32,6 +34,8 @@ import org.junit.Before;
 import org.junit.Test;
 
 import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.security.KeyPair;
@@ -48,12 +52,14 @@ public class OpenIdAuthorizerTest {
 
   private String originalUserFolder;
   private String originalRoleFolder;
+  private String originalOpenIdAudience;
   private Path baseDir;
 
   @Before
   public void setUp() throws IOException {
     originalUserFolder = config.getUserFolder();
     originalRoleFolder = config.getRoleFolder();
+    originalOpenIdAudience = config.getOpenIdAudience();
 
     baseDir = Files.createTempDirectory("openid-authorizer-test-");
     
config.setUserFolder(Files.createDirectories(baseDir.resolve("users")).toString());
@@ -64,6 +70,7 @@ public class OpenIdAuthorizerTest {
   public void tearDown() throws IOException {
     config.setUserFolder(originalUserFolder);
     config.setRoleFolder(originalRoleFolder);
+    config.setOpenIdAudience(originalOpenIdAudience);
 
     if (baseDir != null) {
       try (java.util.stream.Stream<Path> stream = Files.walk(baseDir)) {
@@ -103,6 +110,111 @@ public class OpenIdAuthorizerTest {
     Assert.assertFalse(authorizer.isAdmin(expiredToken));
   }
 
+  @Test
+  public void testWrongIssuerRejected() throws Exception {
+    config.setOpenIdAudience("iotdb");
+    KeyPair keyPair = generateKeyPair();
+    HttpServer server = startProviderServer(keyPair);
+    String issuer = "http://127.0.0.1:"; + server.getAddress().getPort() + "/";
+
+    try {
+      OpenIdAuthorizer authorizer = new OpenIdAuthorizer(issuer);
+      String token =
+          Jwts.builder()
+              .subject("attacker")
+              .issuer("https://evil.example/issuer";)
+              .claim("aud", "iotdb")
+              .expiration(Date.from(Instant.now().plusSeconds(3600)))
+              .claim(
+                  "realm_access",
+                  Collections.singletonMap(
+                      "roles", 
Collections.singletonList(OpenIdAuthorizer.IOTDB_ADMIN_ROLE_NAME)))
+              .signWith(keyPair.getPrivate(), Jwts.SIG.RS256)
+              .compact();
+
+      Assert.assertFalse(authorizer.login(token, "", false));
+      Assert.assertFalse(authorizer.isAdmin(token));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  public void testWrongAudienceRejected() throws Exception {
+    config.setOpenIdAudience("iotdb");
+    KeyPair keyPair = generateKeyPair();
+    HttpServer server = startProviderServer(keyPair);
+    String issuer = "http://127.0.0.1:"; + server.getAddress().getPort() + "/";
+
+    try {
+      OpenIdAuthorizer authorizer = new OpenIdAuthorizer(issuer);
+      String token =
+          Jwts.builder()
+              .subject("attacker")
+              .issuer(issuer)
+              .claim("aud", "unrelated-client")
+              .expiration(Date.from(Instant.now().plusSeconds(3600)))
+              .claim(
+                  "realm_access",
+                  Collections.singletonMap(
+                      "roles", 
Collections.singletonList(OpenIdAuthorizer.IOTDB_ADMIN_ROLE_NAME)))
+              .signWith(keyPair.getPrivate(), Jwts.SIG.RS256)
+              .compact();
+
+      Assert.assertFalse(authorizer.login(token, "", false));
+      Assert.assertFalse(authorizer.isAdmin(token));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  private KeyPair generateKeyPair() throws Exception {
+    KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+    keyPairGenerator.initialize(2048);
+    return keyPairGenerator.generateKeyPair();
+  }
+
+  private HttpServer startProviderServer(KeyPair keyPair) throws Exception {
+    JSONObject publicJwk =
+        new JSONObject(
+            new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
+                .keyUse(KeyUse.SIGNATURE)
+                .keyID("openid-provider-test-key")
+                .build()
+                .toJSONObject());
+
+    HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 
0), 0);
+    String issuer = "http://127.0.0.1:"; + server.getAddress().getPort() + "/";
+    String metadata =
+        "{"
+            + "\"issuer\":\""
+            + issuer
+            + "\","
+            + "\"jwks_uri\":\""
+            + issuer
+            + "jwks.json\","
+            + "\"subject_types_supported\":[\"public\"],"
+            + "\"response_types_supported\":[\"code\"],"
+            + "\"id_token_signing_alg_values_supported\":[\"RS256\"]"
+            + "}";
+    String jwks = "{\"keys\":[" + publicJwk.toJSONString() + "]}";
+
+    server.createContext(
+        "/.well-known/openid-configuration", exchange -> writeJson(exchange, 
metadata));
+    server.createContext("/jwks.json", exchange -> writeJson(exchange, jwks));
+    server.start();
+    return server;
+  }
+
+  private void writeJson(HttpExchange exchange, String json) throws 
IOException {
+    byte[] response = json.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+    exchange.getResponseHeaders().set("Content-Type", "application/json");
+    exchange.sendResponseHeaders(200, response.length);
+    try (OutputStream outputStream = exchange.getResponseBody()) {
+      outputStream.write(response);
+    }
+  }
+
   private void deleteIfExists(Path path) {
     try {
       Files.deleteIfExists(path);

Reply via email to