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);
