Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a3fe97a
initial-jwks-commit
Aug 21, 2025
d1329ff
code refactor
Aug 21, 2025
8b75ce6
code refactor
Aug 21, 2025
4d33e97
code refactor
Aug 22, 2025
452d5b3
code refactor
Aug 22, 2025
caaa8c6
Merge branch 'opensearch-project:main' into jwt-auth-with-jwks
Rishav9852Kumar Aug 22, 2025
38077a7
added response size and jwks count limits
Aug 25, 2025
6a7a517
code refactor
Aug 25, 2025
37e0e51
Merge branch 'opensearch-project:main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 4, 2025
c653789
Merge branch 'opensearch-project:main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 10, 2025
0071e3f
merged the jwks auth in config.yml
Sep 11, 2025
a8eca4e
code-refactor
Sep 11, 2025
6a1c9cb
Merge branch 'opensearch-project:main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 11, 2025
464a542
temp
Sep 12, 2025
7a31689
temp
Sep 12, 2025
a90c414
logic-flow-change
Sep 12, 2025
9153ffa
code-refactor
Sep 14, 2025
eb87375
code-refactor
Sep 14, 2025
669bcd8
code-refactor
Sep 14, 2025
caed0e2
removing comments
Sep 14, 2025
8ee0bc0
adding unit tests and mockjwks-server
Sep 15, 2025
e5dda13
code-refactor
Sep 15, 2025
eadab8f
temp
Sep 15, 2025
7772406
test-fix
Sep 15, 2025
a9a7109
test-refactor
Sep 15, 2025
62fa6d6
Merge branch 'opensearch-project:main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 15, 2025
41c5014
test-refactor
Sep 16, 2025
1901f17
test-refactor
Sep 16, 2025
e97ddcd
Merge branch 'opensearch-project:main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 16, 2025
8836e4d
code-refactor
Sep 17, 2025
60ddf61
indentations fix
Sep 17, 2025
2b7a022
Merge branch 'opensearch-project:main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 19, 2025
a6cd72d
code-changes-done
Sep 19, 2025
0e0308d
test-refactor
Sep 21, 2025
115955f
code modification done
Sep 22, 2025
8262dbe
test-refactor
Sep 22, 2025
ac93d2b
test indentation refactor
Sep 22, 2025
3826416
test indentation refactor
Sep 22, 2025
126f8c4
Merge branch 'main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 22, 2025
f54af46
Merge branch 'main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 23, 2025
b1c41e2
removing changelog to resolve conflict
Sep 26, 2025
03d206b
Merge branch 'main' into jwt-auth-with-jwks
Rishav9852Kumar Sep 26, 2025
b6262ce
resolving comments
Sep 26, 2025
f61c902
added parsing checks around jwks
Sep 26, 2025
68adef6
test-refactor
Sep 26, 2025
9845012
logging-fix
Sep 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- [Resource Sharing] Keep track of tenant for sharable resources by persisting user requested tenant with sharing info ([#5588](https://github.com/opensearch-project/security/pull/5588))
- [SecurityPlugin Health Check] Add AuthZ initialization completion check in health check API [(#5626)](https://github.com/opensearch-project/security/pull/5626)
- [Resource Sharing] Adds API to provide dashboards support for resource access management ([#5597](https://github.com/opensearch-project/security/pull/5597))
- Direct JWKS (JSON Web Key Set) support in the JWT authentication backend ([#5578](https://github.com/opensearch-project/security/pull/5578))


### Bug Fixes
Expand Down
1 change: 1 addition & 0 deletions config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ config:
type: jwt
challenge: false
config:
jwks_uri: 'https://your-jwks-endpoint.com/.well-known/jwks.json'
signing_key: "base64 encoded HMAC key or public RSA/ECDSA pem key"
jwt_header: "Authorization"
jwt_url_parameter: null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

package org.opensearch.security.auth.http.jwt.keybyjwks;

import java.nio.file.Path;
import java.util.Collections;
import java.util.Set;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import org.opensearch.OpenSearchSecurityException;
import org.opensearch.common.settings.Settings;
import org.opensearch.common.util.concurrent.ThreadContext;
import org.opensearch.core.common.Strings;
import org.opensearch.security.auth.http.jwt.AbstractHTTPJwtAuthenticator;
import org.opensearch.security.auth.http.jwt.HTTPJwtAuthenticator;
import org.opensearch.security.auth.http.jwt.keybyoidc.KeyProvider;
import org.opensearch.security.auth.http.jwt.keybyoidc.KeySetRetriever;
import org.opensearch.security.auth.http.jwt.keybyoidc.SelfRefreshingKeySet;
import org.opensearch.security.filter.SecurityRequest;
import org.opensearch.security.user.AuthCredentials;
import org.opensearch.security.util.SettingsBasedSSLConfigurator;

/**
* JWT authenticator that uses JWKS (JSON Web Key Set) endpoints for key retrieval.
*
* This authenticator extends AbstractHTTPJwtAuthenticator and provides JWKS-specific
* key provider initialization. It supports direct JWKS endpoint access with caching,
* SSL configuration, automatic key refresh, and enhanced security features to protect
* against malicious JWKS endpoints.
*
* Security Features:
* - Response size validation before parsing to prevent memory exhaustion
* - Hard key count limit after parsing to reject oversized JWKS
* - Configurable timeouts and rate limiting
*
* Configuration:
* - jwks_uri: Direct JWKS endpoint URL (required)
* - cache_jwks_endpoint: Enable/disable caching (default: true)
* - jwks_request_timeout_ms: Request timeout in milliseconds (default: 5000)
* - jwks_queued_thread_timeout_ms: Queued thread timeout (default: 2500)
* - refresh_rate_limit_time_window_ms: Rate limit window (default: 10000)
* - refresh_rate_limit_count: Max refreshes per window (default: 10)
* - max_jwks_keys: HARD LIMIT - Rejects JWKS if exceeded (default: 10)
* - max_jwks_response_size_bytes: Max HTTP response size (default: 1MB)
*/
public class HTTPJwtKeyByJWKSAuthenticator extends AbstractHTTPJwtAuthenticator {

private final static Logger log = LogManager.getLogger(HTTPJwtKeyByJWKSAuthenticator.class);

// Fallback to static JWT authenticator if jwks_uri is null
private final HTTPJwtAuthenticator staticJwtAuthenticator;
private final boolean useJwks;
private final String jwtUrlParameter;

public HTTPJwtKeyByJWKSAuthenticator(Settings settings, Path configPath) {
super(settings, configPath);

String jwksUri = settings.get("jwks_uri");
this.useJwks = !Strings.isNullOrEmpty(jwksUri);
this.jwtUrlParameter = settings.get("jwt_url_parameter");

// Initialize static JWT authenticator as fallback if jwks_uri is not configured
if (!useJwks) {
log.warn("jwks_uri is not configured, falling back to static JWT authentication");
this.staticJwtAuthenticator = new HTTPJwtAuthenticator(settings, configPath);
} else {
this.staticJwtAuthenticator = null;
}
}

@Override
protected KeyProvider initKeyProvider(Settings settings, Path configPath) throws Exception {
String jwksUri = settings.get("jwks_uri");

// If jwks_uri is not configured, return null (will use static JWT fallback)
if (jwksUri == null || jwksUri.isBlank()) {
log.warn("jwks_uri is not configured, will use static JWT authentication fallback");
return null;
}

log.debug("Initializing JWKS key provider with endpoint: {}", jwksUri);

// Initialize configuration parameters
int jwksRequestTimeoutMs = settings.getAsInt("jwks_request_timeout_ms", 5000);
int jwksQueuedThreadTimeoutMs = settings.getAsInt("jwks_queued_thread_timeout_ms", 2500);
int refreshRateLimitTimeWindowMs = settings.getAsInt("refresh_rate_limit_time_window_ms", 10000);
int refreshRateLimitCount = settings.getAsInt("refresh_rate_limit_count", 10);
boolean cacheJwksEndpoint = settings.getAsBoolean("cache_jwks_endpoint", true);
int maxJwksKeys = settings.getAsInt("max_jwks_keys", -1);

log.warn("Initializing JWKS key provider with endpoint: {} (max keys: {})", jwksUri, maxJwksKeys);

// Add security configuration parameters
long maxJwksResponseSizeBytes = settings.getAsLong("max_jwks_response_size_bytes", 1024L * 1024L); // 1MB default

// Create secure key set retriever with HARD LIMIT enforcement using maxJwksKeys
KeySetRetriever keySetRetriever = KeySetRetriever.createForJwksUri(
getSSLConfig(settings, configPath),
cacheJwksEndpoint,
jwksUri,
maxJwksResponseSizeBytes,
maxJwksKeys
);
keySetRetriever.setRequestTimeoutMs(jwksRequestTimeoutMs);

// Create self-refreshing key set with caching and rate limiting
SelfRefreshingKeySet selfRefreshingKeySet = new SelfRefreshingKeySet(keySetRetriever);
selfRefreshingKeySet.setRequestTimeoutMs(jwksRequestTimeoutMs);
selfRefreshingKeySet.setQueuedThreadTimeoutMs(jwksQueuedThreadTimeoutMs);
selfRefreshingKeySet.setRefreshRateLimitTimeWindowMs(refreshRateLimitTimeWindowMs);
selfRefreshingKeySet.setRefreshRateLimitCount(refreshRateLimitCount);

return selfRefreshingKeySet;
}

@Override
public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext context)
throws OpenSearchSecurityException {

// If jwks_uri is not configured, delegate to static JWT authenticator
if (!useJwks && staticJwtAuthenticator != null) {
log.debug("Delegating to static JWT authenticator since jwks_uri is not configured");
return staticJwtAuthenticator.extractCredentials(request, context);
}

// Use the standard JWKS authentication flow
return super.extractCredentials(request, context);
}

private static SettingsBasedSSLConfigurator.SSLConfig getSSLConfig(Settings settings, Path configPath) throws Exception {
return new SettingsBasedSSLConfigurator(settings, configPath, "jwks").buildSSLConfig();
}

@Override
public String getType() {
return "jwt";
}

@Override
public Set<String> getSensitiveUrlParams() {
if (jwtUrlParameter != null) {
return Set.of(jwtUrlParameter);
}
return Collections.emptySet();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public SignedJWT getVerifiedJwtToken(String encodedJwt) throws BadCredentialsExc
String kid = escapedKid;
if (!Strings.isNullOrEmpty(kid)) {
kid = StringEscapeUtils.unescapeJava(escapedKid);
} else {
log.debug("JWT token is missing 'kid' (Key ID) claim in header. This may cause key selection issues.");
}
JWK key = keyProvider.getKey(kid);

Expand All @@ -65,8 +67,10 @@ public SignedJWT getVerifiedJwtToken(String encodedJwt) throws BadCredentialsExc

if (!signatureValid && Strings.isNullOrEmpty(kid)) {
key = keyProvider.getKeyAfterRefresh(null);
signatureVerifier = getInitializedSignatureVerifier(key, jwt);
signatureValid = jwt.verify(signatureVerifier);
if (key != null) {
signatureVerifier = getInitializedSignatureVerifier(key, jwt);
signatureValid = jwt.verify(signatureVerifier);
}
}

if (!signatureValid) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public class KeySetRetriever implements KeySetProvider {
private long lastCacheStatusLog = 0;
private String jwksUri;

// Security validation settings (optional, for JWKS endpoints)
private long maxResponseSizeBytes = -1; // -1 means no limit
private int maxKeyCount = -1; // -1 means no limit
private boolean enableSecurityValidation = false;

KeySetRetriever(String openIdConnectEndpoint, SSLConfig sslConfig, boolean useCacheForOidConnectEndpoint) {
this.openIdConnectEndpoint = openIdConnectEndpoint;
this.sslConfig = sslConfig;
Expand All @@ -71,10 +76,41 @@ public class KeySetRetriever implements KeySetProvider {
configureCache(useCacheForOidConnectEndpoint);
}

/**
* Factory method to create a KeySetRetriever for JWKS endpoint access.
* This method provides a public API for creating KeySetRetriever instances
* with built-in security validation to protect against malicious JWKS endpoints.
*
* @param sslConfig SSL configuration for HTTPS connections
* @param useCacheForJwksEndpoint whether to enable caching for JWKS endpoint
* When true, JWKS responses will be cached to improve performance
* and reduce network calls to the JWKS endpoint.
* @param jwksUri the JWKS endpoint URI
* @param maxResponseSizeBytes maximum allowed HTTP response size in bytes
* @param maxKeyCount maximum number of keys allowed in JWKS
* @return a new KeySetRetriever instance with security validation enabled
*/
public static KeySetRetriever createForJwksUri(
SSLConfig sslConfig,
boolean useCacheForJwksEndpoint,
String jwksUri,
long maxResponseSizeBytes,
int maxKeyCount
) {
KeySetRetriever retriever = new KeySetRetriever(sslConfig, useCacheForJwksEndpoint, jwksUri);
retriever.enableSecurityValidation = true;
retriever.maxResponseSizeBytes = maxResponseSizeBytes;
retriever.maxKeyCount = maxKeyCount;
return retriever;
}

public JWKSet get() throws AuthenticatorUnavailableException {
String uri = getJwksUri();

try (CloseableHttpClient httpClient = createHttpClient(null)) {
// Use cache storage if it's configured
HttpCacheStorage cacheStorage = oidcHttpCacheStorage;

try (CloseableHttpClient httpClient = createHttpClient(cacheStorage)) {

HttpGet httpGet = new HttpGet(uri);

Expand All @@ -85,7 +121,20 @@ public JWKSet get() throws AuthenticatorUnavailableException {

httpGet.setConfig(requestConfig);

try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
// Configure HTTP client to only accept JSON responses for JWKS endpoints
if (enableSecurityValidation) {
httpGet.setHeader("Accept", "application/json, application/jwk-set+json");
}

HttpCacheContext httpContext = null;
if (cacheStorage != null) {
httpContext = new HttpCacheContext();
}

try (CloseableHttpResponse response = httpClient.execute(httpGet, httpContext)) {
if (httpContext != null) {
logCacheResponseStatus(httpContext, true);
}
if (response.getCode() < 200 || response.getCode() >= 300) {
throw new AuthenticatorUnavailableException("Error while getting " + uri + ": " + response.getReasonPhrase());
}
Expand All @@ -95,11 +144,41 @@ public JWKSet get() throws AuthenticatorUnavailableException {
if (httpEntity == null) {
throw new AuthenticatorUnavailableException("Error while getting " + uri + ": Empty response entity");
}

// Apply security validation if enabled (for JWKS endpoints)
if (enableSecurityValidation) {
// Validate response size
if (maxResponseSizeBytes > 0) {
long contentLength = httpEntity.getContentLength();
if (contentLength > maxResponseSizeBytes) {
throw new AuthenticatorUnavailableException(
String.format(
"JWKS response too large from %s: %d bytes (max: %d)",
uri,
contentLength,
maxResponseSizeBytes
)
);
}
}
}

// Load JWKS using Nimbus JOSE (handles JSON parsing and validation)
JWKSet keySet = JWKSet.load(httpEntity.getContent());

// Apply minimal additional validation only for direct JWKS endpoints
if (enableSecurityValidation) {
// Simple key count validation - HARD LIMIT
if (maxKeyCount > 0 && keySet.getKeys().size() > maxKeyCount) {
throw new AuthenticatorUnavailableException(
String.format("JWKS from %s contains %d keys, but max allowed is %d", uri, keySet.getKeys().size(), maxKeyCount)
);
}
}

return keySet;
} catch (ParseException e) {
throw new RuntimeException(e);
throw new AuthenticatorUnavailableException("Error parsing JWKS from " + uri + ": " + e.getMessage(), e);
}
} catch (IOException e) {
throw new AuthenticatorUnavailableException("Error while getting " + uri + ": " + e, e);
Expand Down Expand Up @@ -177,21 +256,43 @@ public void setRequestTimeoutMs(int httpTimeoutMs) {
}

private void logCacheResponseStatus(HttpCacheContext httpContext) {
logCacheResponseStatus(httpContext, false);
}

private void logCacheResponseStatus(HttpCacheContext httpContext, boolean isJwksRequest) {
this.oidcRequests++;

switch (httpContext.getCacheResponseStatus()) {
case CACHE_HIT:
this.oidcCacheHits++;
break;
case CACHE_MODULE_RESPONSE:
this.oidcCacheModuleResponses++;
break;
case CACHE_MISS:
// Handle cache statistics based on the response status
// For OIDC discovery flow, only count the JWKS request (not the discovery request)
// For direct JWKS URI, count all requests
boolean shouldCountStats = (jwksUri != null) || isJwksRequest;

if (!shouldCountStats) {
log.debug("Skipping cache statistics for OIDC discovery request #{}", this.oidcRequests);
return;
}

if (httpContext.getCacheResponseStatus() == null) {
if (oidcHttpCacheStorage != null) {
this.oidcCacheMisses++;
break;
case VALIDATED:
this.oidcCacheHitsValidated++;
break;
log.debug("Null cache status - counting as cache miss. Total misses: {}", this.oidcCacheMisses);
}
} else {
switch (httpContext.getCacheResponseStatus()) {
case CACHE_HIT:
this.oidcCacheHits++;
break;
case CACHE_MODULE_RESPONSE:
this.oidcCacheModuleResponses++;
break;
case CACHE_MISS:
this.oidcCacheMisses++;
break;
case VALIDATED:
this.oidcCacheHits++;
this.oidcCacheHitsValidated++;
break;
}
}

long now = System.currentTimeMillis();
Expand All @@ -208,7 +309,6 @@ private void logCacheResponseStatus(HttpCacheContext httpContext) {
);
lastCacheStatusLog = now;
}

}

private CloseableHttpClient createHttpClient(HttpCacheStorage httpCacheStorage) {
Expand Down Expand Up @@ -255,4 +355,5 @@ public int getOidcCacheHitsValidated() {
public int getOidcCacheModuleResponses() {
return oidcCacheModuleResponses;
}

}
Loading
Loading