feat(identity): secure dynamic oidc metadata

This commit is contained in:
XiaoSeS 2026-09-08 06:35:44 +08:00
parent a2a86d3bb6
commit ce768268a7
20 changed files with 1459 additions and 0 deletions

View file

@ -0,0 +1,149 @@
package com.iflytek.skillhub.auth.connection.oidc;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.jwk.JWKSet;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
/** Strict parser and secure remote loader for OIDC discovery metadata and public JWK sets. */
public final class DefaultOidcMetadataSource implements OidcMetadataSource {
private static final Set<String> SAFE_SIGNING_ALGORITHMS = Set.of(
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
"ES256", "ES384", "ES512", "EdDSA"
);
private final SecureOidcDocumentClient documents;
private final OidcOutboundTargetPolicy targetPolicy;
private final ObjectMapper objectMapper;
public DefaultOidcMetadataSource(
SecureOidcDocumentClient documents,
OidcOutboundTargetPolicy targetPolicy
) {
this.documents = Objects.requireNonNull(documents, "documents");
this.targetPolicy = Objects.requireNonNull(targetPolicy, "targetPolicy");
JsonFactory factory = JsonFactory.builder()
.streamReadConstraints(StreamReadConstraints.builder()
.maxNestingDepth(20)
.maxStringLength(16 * 1024)
.maxNumberLength(128)
.build())
.build();
factory.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
this.objectMapper = new ObjectMapper(factory);
}
@Override
public OidcMetadataBundle load(OidcIssuer issuer) {
OidcIssuer requiredIssuer = Objects.requireNonNull(issuer, "issuer");
OidcProviderMetadata metadata = parseMetadata(
requiredIssuer,
documents.get(requiredIssuer.discoveryUri(), OidcDocumentKind.METADATA)
);
return new OidcMetadataBundle(metadata, refreshKeys(metadata));
}
@Override
public OidcJwkSetSnapshot refreshKeys(OidcProviderMetadata metadata) {
OidcProviderMetadata requiredMetadata = Objects.requireNonNull(metadata, "metadata");
byte[] json = documents.get(requiredMetadata.jwksUri(), OidcDocumentKind.JWKS);
try {
JWKSet parsed = JWKSet.parse(new String(json, StandardCharsets.UTF_8));
return new OidcJwkSetSnapshot(parsed.getKeys());
} catch (ParseException | RuntimeException exception) {
throw new OidcMetadataUnavailableException();
}
}
private OidcProviderMetadata parseMetadata(OidcIssuer expectedIssuer, byte[] json) {
try {
JsonNode root = objectMapper.readTree(json);
if (root == null || !root.isObject()) {
throw new OidcMetadataUnavailableException();
}
String returnedIssuer = requiredText(root, "issuer");
if (!expectedIssuer.value().equals(returnedIssuer)) {
throw new OidcMetadataUnavailableException();
}
URI authorizationEndpoint = endpoint(root, "authorization_endpoint");
URI tokenEndpoint = endpoint(root, "token_endpoint");
URI jwksUri = endpoint(root, "jwks_uri");
targetPolicy.verify(authorizationEndpoint);
targetPolicy.verify(tokenEndpoint);
targetPolicy.verify(jwksUri);
Set<String> responseTypes = stringSet(root, "response_types_supported", 32);
if (!responseTypes.contains("code")) {
throw new OidcMetadataUnavailableException();
}
Set<String> algorithms = stringSet(
root,
"id_token_signing_alg_values_supported",
32
);
algorithms.retainAll(SAFE_SIGNING_ALGORITHMS);
if (algorithms.isEmpty()) {
throw new OidcMetadataUnavailableException();
}
return new OidcProviderMetadata(
expectedIssuer,
authorizationEndpoint,
tokenEndpoint,
jwksUri,
algorithms
);
} catch (OidcMetadataUnavailableException exception) {
throw exception;
} catch (Exception exception) {
throw new OidcMetadataUnavailableException();
}
}
private static URI endpoint(JsonNode root, String field) {
URI uri;
try {
uri = URI.create(requiredText(root, field));
} catch (IllegalArgumentException exception) {
throw new OidcMetadataUnavailableException();
}
if (!"https".equals(uri.getScheme())
|| uri.getHost() == null
|| uri.getRawUserInfo() != null
|| uri.getRawFragment() != null) {
throw new OidcMetadataUnavailableException();
}
return uri;
}
private static String requiredText(JsonNode root, String field) {
JsonNode value = root.get(field);
if (value == null || !value.isTextual() || value.textValue().isBlank()) {
throw new OidcMetadataUnavailableException();
}
return value.textValue();
}
private static Set<String> stringSet(JsonNode root, String field, int maximum) {
JsonNode value = root.get(field);
if (value == null || !value.isArray() || value.isEmpty() || value.size() > maximum) {
throw new OidcMetadataUnavailableException();
}
Set<String> result = new HashSet<>();
for (JsonNode item : value) {
if (!item.isTextual() || item.textValue().isBlank() || item.textValue().length() > 128) {
throw new OidcMetadataUnavailableException();
}
result.add(item.textValue());
}
return result;
}
}

View file

@ -0,0 +1,49 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Objects;
/** JDK HTTP transport with TLS verification and automatic redirects disabled. */
public final class JdkOidcSingleHopHttpTransport implements OidcSingleHopHttpTransport {
static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
private final HttpClient client;
public JdkOidcSingleHopHttpTransport() {
this(CONNECT_TIMEOUT);
}
JdkOidcSingleHopHttpTransport(Duration connectTimeout) {
this(HttpClient.newBuilder()
.connectTimeout(Objects.requireNonNull(connectTimeout, "connectTimeout"))
.followRedirects(HttpClient.Redirect.NEVER)
.build());
}
JdkOidcSingleHopHttpTransport(HttpClient client) {
this.client = Objects.requireNonNull(client, "client");
}
@Override
public OidcHttpResponse get(URI uri, Duration timeout) throws Exception {
HttpRequest request = HttpRequest.newBuilder(Objects.requireNonNull(uri, "uri"))
.timeout(Objects.requireNonNull(timeout, "timeout"))
.header("Accept", "application/json, application/jwk-set+json")
.GET()
.build();
HttpResponse<java.io.InputStream> response = client.send(
request,
HttpResponse.BodyHandlers.ofInputStream()
);
return new OidcHttpResponse(
response.statusCode(),
response.headers().map(),
response.body()
);
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.auth.connection.oidc;
/** Bounded remote OIDC JSON document types. */
public enum OidcDocumentKind {
METADATA(64 * 1024),
JWKS(512 * 1024);
private final int maxBytes;
OidcDocumentKind(int maxBytes) {
this.maxBytes = maxBytes;
}
int maxBytes() {
return maxBytes;
}
}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.List;
/** DNS seam used to validate every outbound OIDC request target before connecting. */
@FunctionalInterface
public interface OidcHostAddressResolver {
List<InetAddress> resolve(String host) throws UnknownHostException;
static OidcHostAddressResolver system() {
return host -> List.of(InetAddress.getAllByName(host));
}
}

View file

@ -0,0 +1,30 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** One non-followed HTTP response; callers must close the body. */
public record OidcHttpResponse(int status, Map<String, List<String>> headers, InputStream body)
implements AutoCloseable {
public OidcHttpResponse {
headers = Map.copyOf(Objects.requireNonNull(headers, "headers"));
body = Objects.requireNonNull(body, "body");
}
public String firstHeader(String name) {
return headers.entrySet().stream()
.filter(entry -> entry.getKey().equalsIgnoreCase(name))
.flatMap(entry -> entry.getValue().stream())
.findFirst()
.orElse(null);
}
@Override
public void close() throws IOException {
body.close();
}
}

View file

@ -0,0 +1,52 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Objects;
/** Canonical configured OIDC issuer whose value is used for exact metadata/token matching. */
public record OidcIssuer(String value, URI uri) {
public OidcIssuer(String value) {
this(requireCanonicalValue(value), parse(value));
}
public OidcIssuer {
value = requireCanonicalValue(value);
uri = Objects.requireNonNull(uri, "uri");
if (!value.equals(uri.toASCIIString())
|| !"https".equals(uri.getScheme())
|| uri.getHost() == null
|| uri.getHost().isBlank()
|| uri.getRawUserInfo() != null
|| uri.getRawQuery() != null
|| uri.getRawFragment() != null) {
throw new IllegalArgumentException("OIDC issuer must be a canonical HTTPS URL");
}
if (!uri.getHost().equals(uri.getHost().toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("OIDC issuer host must be lowercase");
}
}
public URI discoveryUri() {
String prefix = value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
return URI.create(prefix + "/.well-known/openid-configuration");
}
private static URI parse(String value) {
try {
return new URI(requireCanonicalValue(value));
} catch (URISyntaxException exception) {
throw new IllegalArgumentException("OIDC issuer must be a canonical HTTPS URL");
}
}
private static String requireCanonicalValue(String value) {
Objects.requireNonNull(value, "value");
if (value.isBlank() || !value.equals(value.trim())) {
throw new IllegalArgumentException("OIDC issuer must be a canonical HTTPS URL");
}
return value;
}
}

View file

@ -0,0 +1,50 @@
package com.iflytek.skillhub.auth.connection.oidc;
import com.nimbusds.jose.jwk.JWK;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/** Validated public JWK set with deterministic key-id lookup. */
public record OidcJwkSetSnapshot(List<JWK> keys) {
public OidcJwkSetSnapshot {
keys = List.copyOf(Objects.requireNonNull(keys, "keys"));
if (keys.isEmpty() || keys.size() > 64) {
throw new OidcMetadataUnavailableException();
}
if (keys.stream().anyMatch(JWK::isPrivate)) {
throw new OidcMetadataUnavailableException();
}
Set<String> keyIds = new HashSet<>();
if (keys.stream()
.map(JWK::getKeyID)
.filter(Objects::nonNull)
.anyMatch(keyId -> keyId.isBlank()
|| keyId.length() > 128
|| !keyIds.add(keyId))) {
throw new OidcMetadataUnavailableException();
}
}
public JWK findByKeyId(String keyId) {
if (keyId == null || keyId.isBlank() || keyId.length() > 128) {
throw new OidcMetadataUnavailableException();
}
List<JWK> matching = keys.stream()
.filter(key -> keyId.equals(key.getKeyID()))
.toList();
if (matching.size() != 1) {
throw new OidcMetadataUnavailableException();
}
return matching.get(0);
}
public boolean containsKeyId(String keyId) {
if (keyId == null || keyId.isBlank() || keyId.length() > 128) {
return false;
}
return keys.stream().filter(key -> keyId.equals(key.getKeyID())).limit(2).count() == 1;
}
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.util.Objects;
/** One internally consistent metadata/JWKS observation. */
public record OidcMetadataBundle(
OidcProviderMetadata metadata,
OidcJwkSetSnapshot jwkSet
) {
public OidcMetadataBundle {
metadata = Objects.requireNonNull(metadata, "metadata");
jwkSet = Objects.requireNonNull(jwkSet, "jwkSet");
}
}

View file

@ -0,0 +1,24 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.util.Objects;
/** Revision-pinned cache coordinate; a config switch cannot reuse old metadata. */
public record OidcMetadataCacheKey(String scopeKey, String connectionId, long revision) {
public OidcMetadataCacheKey {
scopeKey = requireText(scopeKey, "scopeKey");
connectionId = requireText(connectionId, "connectionId");
if (revision < 1) {
throw new IllegalArgumentException("revision must be positive");
}
}
private static String requireText(String value, String field) {
Objects.requireNonNull(value, field);
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return normalized;
}
}

View file

@ -0,0 +1,176 @@
package com.iflytek.skillhub.auth.connection.oidc;
import com.iflytek.skillhub.auth.federation.adapter.RemoteIdentityIoExecutor;
import com.nimbusds.jose.jwk.JWK;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/** Revision-keyed metadata cache with bounded refresh on an unknown token key id. */
public final class OidcMetadataManager {
private static final int LOCK_STRIPES = 64;
static final Duration DEFAULT_TIME_TO_LIVE = Duration.ofMinutes(15);
static final Duration DEFAULT_KID_MISS_REFRESH_COOLDOWN = Duration.ofSeconds(30);
private final OidcMetadataSource source;
private final RemoteIdentityIoExecutor remoteIdentityIo;
private final Duration timeToLive;
private final Duration kidMissRefreshCooldown;
private final Map<OidcMetadataCacheKey, CachedBundle> cache = new ConcurrentHashMap<>();
private final Map<OidcMetadataCacheKey, Instant> lastKidMissRefresh = new ConcurrentHashMap<>();
private final Object[] locks = new Object[LOCK_STRIPES];
public OidcMetadataManager(
OidcMetadataSource source,
RemoteIdentityIoExecutor remoteIdentityIo
) {
this(
source,
remoteIdentityIo,
DEFAULT_TIME_TO_LIVE,
DEFAULT_KID_MISS_REFRESH_COOLDOWN
);
}
public OidcMetadataManager(
OidcMetadataSource source,
RemoteIdentityIoExecutor remoteIdentityIo,
Duration timeToLive,
Duration kidMissRefreshCooldown
) {
this.source = Objects.requireNonNull(source, "source");
this.remoteIdentityIo = Objects.requireNonNull(remoteIdentityIo, "remoteIdentityIo");
this.timeToLive = requireDuration(timeToLive, Duration.ofSeconds(30), Duration.ofHours(24));
this.kidMissRefreshCooldown = requireDuration(
kidMissRefreshCooldown,
Duration.ofSeconds(5),
Duration.ofMinutes(5)
);
java.util.Arrays.setAll(locks, ignored -> new Object());
}
public OidcMetadataBundle get(
OidcMetadataCacheKey key,
OidcIssuer issuer,
Instant now
) {
OidcMetadataCacheKey requiredKey = Objects.requireNonNull(key, "key");
Instant currentTime = Objects.requireNonNull(now, "now");
CachedBundle current = cache.get(requiredKey);
if (current != null && current.expiresAt().isAfter(currentTime)) {
requireIssuer(current.bundle(), issuer);
return current.bundle();
}
synchronized (lockFor(requiredKey)) {
current = cache.get(requiredKey);
if (current != null && current.expiresAt().isAfter(currentTime)) {
requireIssuer(current.bundle(), issuer);
return current.bundle();
}
OidcMetadataBundle loaded = safeRemote(() -> source.load(issuer));
requireIssuer(loaded, issuer);
cache.put(requiredKey, new CachedBundle(
loaded,
currentTime,
currentTime.plus(timeToLive)
));
return loaded;
}
}
public JWK resolveKey(
OidcMetadataCacheKey key,
OidcIssuer issuer,
String keyId,
Instant now
) {
OidcMetadataBundle current = get(key, issuer, now);
if (current.jwkSet().containsKeyId(keyId)) {
return current.jwkSet().findByKeyId(keyId);
}
synchronized (lockFor(key)) {
CachedBundle latest = cache.get(key);
if (latest != null && latest.bundle().jwkSet().containsKeyId(keyId)) {
return latest.bundle().jwkSet().findByKeyId(keyId);
}
Instant lastRefresh = lastKidMissRefresh.get(key);
if (lastRefresh != null && now.isBefore(lastRefresh.plus(kidMissRefreshCooldown))) {
throw new OidcMetadataUnavailableException();
}
OidcJwkSetSnapshot refreshed = safeRemote(
() -> source.refreshKeys(
latest == null ? current.metadata() : latest.bundle().metadata()
)
);
lastKidMissRefresh.put(key, now);
OidcMetadataBundle replacement = new OidcMetadataBundle(
latest == null ? current.metadata() : latest.bundle().metadata(),
refreshed
);
cache.put(key, new CachedBundle(replacement, now, now.plus(timeToLive)));
return refreshed.findByKeyId(keyId);
}
}
public void invalidate(OidcMetadataCacheKey key) {
cache.remove(Objects.requireNonNull(key, "key"));
lastKidMissRefresh.remove(key);
}
public void invalidateConnection(String scopeKey, String connectionId) {
String requiredScope = requireText(scopeKey, "scopeKey");
String requiredConnection = requireText(connectionId, "connectionId");
cache.keySet().removeIf(key -> key.scopeKey().equals(requiredScope)
&& key.connectionId().equals(requiredConnection));
lastKidMissRefresh.keySet().removeIf(key -> key.scopeKey().equals(requiredScope)
&& key.connectionId().equals(requiredConnection));
}
private static void requireIssuer(OidcMetadataBundle bundle, OidcIssuer issuer) {
if (!bundle.metadata().issuer().equals(Objects.requireNonNull(issuer, "issuer"))) {
throw new OidcMetadataUnavailableException();
}
}
private static Duration requireDuration(Duration value, Duration minimum, Duration maximum) {
Duration duration = Objects.requireNonNull(value, "value");
if (duration.compareTo(minimum) < 0 || duration.compareTo(maximum) > 0) {
throw new IllegalArgumentException("OIDC cache duration is outside safe bounds");
}
return duration;
}
private <T> T safeRemote(java.util.function.Supplier<T> operation) {
try {
return remoteIdentityIo.execute(operation);
} catch (OidcMetadataUnavailableException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new OidcMetadataUnavailableException();
}
}
private Object lockFor(OidcMetadataCacheKey key) {
int index = (key.hashCode() & Integer.MAX_VALUE) % locks.length;
return locks[index];
}
private static String requireText(String value, String field) {
Objects.requireNonNull(value, field);
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return normalized;
}
private record CachedBundle(
OidcMetadataBundle bundle,
Instant fetchedAt,
Instant expiresAt
) {
}
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.auth.connection.oidc;
/** Remote metadata source kept outside database transactions by its caller. */
public interface OidcMetadataSource {
OidcMetadataBundle load(OidcIssuer issuer);
OidcJwkSetSnapshot refreshKeys(OidcProviderMetadata metadata);
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.auth.connection.oidc;
/** Enumeration-resistant failure for untrusted OIDC metadata and key retrieval. */
public final class OidcMetadataUnavailableException extends RuntimeException {
public OidcMetadataUnavailableException() {
super("OIDC provider metadata is unavailable", null, false, false);
}
}

View file

@ -0,0 +1,130 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** HTTPS and IP policy for metadata/JWKS SSRF control. */
public final class OidcOutboundTargetPolicy {
private final OidcHostAddressResolver resolver;
private final Set<String> operatorAllowedPrivateHosts;
public OidcOutboundTargetPolicy(
OidcHostAddressResolver resolver,
Set<String> operatorAllowedPrivateHosts
) {
this.resolver = Objects.requireNonNull(resolver, "resolver");
this.operatorAllowedPrivateHosts = Set.copyOf(
Objects.requireNonNull(operatorAllowedPrivateHosts, "operatorAllowedPrivateHosts")
.stream()
.map(OidcOutboundTargetPolicy::normalizeAllowedPrivateHost)
.toList()
);
}
public void verify(URI uri) {
Objects.requireNonNull(uri, "uri");
String host = uri.getHost();
if (!"https".equals(uri.getScheme())
|| host == null
|| host.isBlank()
|| uri.getRawUserInfo() != null
|| uri.getRawFragment() != null) {
throw new OidcMetadataUnavailableException();
}
String normalizedHost = host.toLowerCase(Locale.ROOT);
InetAddress[] addresses;
try {
addresses = resolver.resolve(normalizedHost).toArray(InetAddress[]::new);
} catch (UnknownHostException | RuntimeException exception) {
throw new OidcMetadataUnavailableException();
}
if (addresses.length == 0) {
throw new OidcMetadataUnavailableException();
}
if (!operatorAllowedPrivateHosts.contains(normalizedHost)) {
for (InetAddress address : addresses) {
if (!isPublic(address)) {
throw new OidcMetadataUnavailableException();
}
}
}
}
private static String normalizeAllowedPrivateHost(String host) {
Objects.requireNonNull(host, "operatorAllowedPrivateHosts entry");
String normalized = host.toLowerCase(Locale.ROOT);
if (!host.equals(host.trim())
|| normalized.isBlank()
|| normalized.length() > 253
|| normalized.startsWith(".")
|| normalized.endsWith(".")
|| normalized.indexOf('*') >= 0
|| normalized.chars().anyMatch(character -> character > 0x7f)) {
throw new IllegalArgumentException(
"OIDC private host allowlist entries must be exact ASCII hostnames"
);
}
try {
URI parsed = new URI("https", null, normalized, -1, null, null, null);
if (!normalized.equals(parsed.getHost()) || looksLikeIpLiteral(normalized)) {
throw new IllegalArgumentException(
"OIDC private host allowlist entries must be exact ASCII hostnames"
);
}
} catch (URISyntaxException exception) {
throw new IllegalArgumentException(
"OIDC private host allowlist entries must be exact ASCII hostnames"
);
}
return normalized;
}
private static boolean looksLikeIpLiteral(String host) {
return host.indexOf(':') >= 0 || host.chars().allMatch(character ->
character == '.' || character >= '0' && character <= '9'
);
}
private static boolean isPublic(InetAddress address) {
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()) {
return false;
}
byte[] bytes = address.getAddress();
if (address instanceof Inet4Address) {
int first = Byte.toUnsignedInt(bytes[0]);
int second = Byte.toUnsignedInt(bytes[1]);
int third = Byte.toUnsignedInt(bytes[2]);
return first != 0
&& !(first == 100 && second >= 64 && second <= 127)
&& !(first == 192 && second == 0 && third == 0)
&& !(first == 192 && second == 0 && third == 2)
&& !(first == 198 && (second == 18 || second == 19))
&& !(first == 198 && second == 51 && third == 100)
&& !(first == 203 && second == 0 && third == 113)
&& first < 224;
}
if (address instanceof Inet6Address) {
int first = Byte.toUnsignedInt(bytes[0]);
int second = Byte.toUnsignedInt(bytes[1]);
boolean uniqueLocal = (first & 0xfe) == 0xfc;
boolean documentation = first == 0x20
&& second == 0x01
&& Byte.toUnsignedInt(bytes[2]) == 0x0d
&& Byte.toUnsignedInt(bytes[3]) == 0xb8;
return !uniqueLocal && !documentation;
}
return false;
}
}

View file

@ -0,0 +1,29 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.net.URI;
import java.util.Objects;
import java.util.Set;
/** Validated provider metadata used by later authorization and token validation stages. */
public record OidcProviderMetadata(
OidcIssuer issuer,
URI authorizationEndpoint,
URI tokenEndpoint,
URI jwksUri,
Set<String> idTokenSigningAlgorithms
) {
public OidcProviderMetadata {
issuer = Objects.requireNonNull(issuer, "issuer");
authorizationEndpoint = Objects.requireNonNull(
authorizationEndpoint,
"authorizationEndpoint"
);
tokenEndpoint = Objects.requireNonNull(tokenEndpoint, "tokenEndpoint");
jwksUri = Objects.requireNonNull(jwksUri, "jwksUri");
idTokenSigningAlgorithms = Set.copyOf(Objects.requireNonNull(
idTokenSigningAlgorithms,
"idTokenSigningAlgorithms"
));
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.net.URI;
import java.time.Duration;
/** HTTP seam that performs exactly one request and never follows redirects. */
@FunctionalInterface
public interface OidcSingleHopHttpTransport {
OidcHttpResponse get(URI uri, Duration timeout) throws Exception;
}

View file

@ -0,0 +1,74 @@
package com.iflytek.skillhub.auth.connection.oidc;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
/** Bounded redirect-aware fetcher that verifies every outbound target before one-hop I/O. */
public final class SecureOidcDocumentClient {
static final int MAX_REDIRECTS = 3;
static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
private static final Set<Integer> REDIRECT_STATUSES = Set.of(301, 302, 303, 307, 308);
private final OidcOutboundTargetPolicy targetPolicy;
private final OidcSingleHopHttpTransport transport;
public SecureOidcDocumentClient(
OidcOutboundTargetPolicy targetPolicy,
OidcSingleHopHttpTransport transport
) {
this.targetPolicy = Objects.requireNonNull(targetPolicy, "targetPolicy");
this.transport = Objects.requireNonNull(transport, "transport");
}
public byte[] get(URI initialUri, OidcDocumentKind kind) {
URI current = Objects.requireNonNull(initialUri, "initialUri");
OidcDocumentKind documentKind = Objects.requireNonNull(kind, "kind");
for (int redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
targetPolicy.verify(current);
try (OidcHttpResponse response = transport.get(current, REQUEST_TIMEOUT)) {
if (response.status() == 200) {
requireJsonContentType(response.firstHeader("Content-Type"));
byte[] bytes = response.body().readNBytes(documentKind.maxBytes() + 1);
if (bytes.length > documentKind.maxBytes()) {
throw new OidcMetadataUnavailableException();
}
return bytes;
}
if (!REDIRECT_STATUSES.contains(response.status()) || redirects == MAX_REDIRECTS) {
throw new OidcMetadataUnavailableException();
}
String location = response.firstHeader("Location");
if (location == null) {
throw new OidcMetadataUnavailableException();
}
current = current.resolve(location);
} catch (OidcMetadataUnavailableException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new OidcMetadataUnavailableException();
} catch (IOException | RuntimeException exception) {
throw new OidcMetadataUnavailableException();
} catch (Exception exception) {
throw new OidcMetadataUnavailableException();
}
}
throw new OidcMetadataUnavailableException();
}
private static void requireJsonContentType(String contentType) {
if (contentType == null) {
throw new OidcMetadataUnavailableException();
}
String normalized = contentType.split(";", 2)[0].trim()
.toLowerCase(java.util.Locale.ROOT);
if (!normalized.equals("application/json")
&& !normalized.equals("application/jwk-set+json")) {
throw new OidcMetadataUnavailableException();
}
}
}

View file

@ -0,0 +1,2 @@
/** Dynamic OIDC metadata, JWKS and outbound network security boundaries. */
package com.iflytek.skillhub.auth.connection.oidc;

View file

@ -0,0 +1,149 @@
package com.iflytek.skillhub.auth.connection.oidc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayInputStream;
import java.net.InetAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import org.junit.jupiter.api.Test;
class DefaultOidcMetadataSourceTest {
private static final OidcIssuer ISSUER = new OidcIssuer("https://id.example.com/tenant");
@Test
void loadsStrictMetadataAndPublicJwks() {
QueueTransport transport = new QueueTransport(
metadata(ISSUER.value(), "https://keys.example.com/jwks", "RS256"),
jwks("signing-key")
);
DefaultOidcMetadataSource source = source(transport, publicResolver());
OidcMetadataBundle bundle = source.load(ISSUER);
assertThat(bundle.metadata().issuer()).isEqualTo(ISSUER);
assertThat(bundle.metadata().authorizationEndpoint())
.isEqualTo(URI.create("https://id.example.com/authorize"));
assertThat(bundle.metadata().idTokenSigningAlgorithms()).containsExactly("RS256");
assertThat(bundle.jwkSet().findByKeyId("signing-key").getKeyID())
.isEqualTo("signing-key");
assertThat(transport.requests).containsExactly(
ISSUER.discoveryUri(),
URI.create("https://keys.example.com/jwks")
);
}
@Test
void rejectsIssuerMismatchDuplicateFieldsUnsafeAlgorithmsAndPrivateEndpoints() {
assertUnavailable(() -> source(new QueueTransport(
metadata("https://attacker.example", "https://keys.example.com/jwks", "RS256")
), publicResolver()).load(ISSUER));
String duplicateIssuer = metadata(ISSUER.value(), "https://keys.example.com/jwks", "RS256")
.replaceFirst("\\{", "{\"issuer\":\"https://duplicate.example\",");
assertUnavailable(() -> source(
new QueueTransport(duplicateIssuer),
publicResolver()
).load(ISSUER));
assertUnavailable(() -> source(new QueueTransport(
metadata(ISSUER.value(), "https://keys.example.com/jwks", "none")
), publicResolver()).load(ISSUER));
OidcHostAddressResolver privateKeys = host -> host.equals("keys.example.com")
? List.of(InetAddress.getByName("10.1.2.3"))
: publicResolver().resolve(host);
assertUnavailable(() -> source(new QueueTransport(
metadata(ISSUER.value(), "https://keys.example.com/jwks", "RS256")
), privateKeys).load(ISSUER));
assertUnavailable(() -> source(new QueueTransport(
metadata(ISSUER.value(), "https://keys.example.com/jwks", "RS256"),
"{\"keys\":[{\"kty\":\"oct\",\"kid\":\"symmetric\",\"k\":\"AQAB\"}]}"
), publicResolver()).load(ISSUER));
assertUnavailable(() -> source(new QueueTransport(
metadata(ISSUER.value(), "https://keys.example.com/jwks", "RS256"),
"""
{"keys":[
{"kty":"RSA","kid":"duplicate","n":"AQAB","e":"AQAB"},
{"kty":"RSA","kid":"duplicate","n":"AQAB","e":"AQAB"}
]}
"""
), publicResolver()).load(ISSUER));
}
private static DefaultOidcMetadataSource source(
QueueTransport transport,
OidcHostAddressResolver resolver
) {
OidcOutboundTargetPolicy policy = new OidcOutboundTargetPolicy(resolver, Set.of());
return new DefaultOidcMetadataSource(
new SecureOidcDocumentClient(policy, transport),
policy
);
}
private static OidcHostAddressResolver publicResolver() {
return host -> List.of(InetAddress.getByName("93.184.216.34"));
}
private static String metadata(String issuer, String jwksUri, String algorithm) {
return """
{
"issuer": "%s",
"authorization_endpoint": "https://id.example.com/authorize",
"token_endpoint": "https://id.example.com/token",
"jwks_uri": "%s",
"response_types_supported": ["code"],
"id_token_signing_alg_values_supported": ["%s"]
}
""".formatted(issuer, jwksUri, algorithm);
}
static String jwks(String keyId) {
return """
{"keys":[{"kty":"RSA","kid":"%s","n":"AQAB","e":"AQAB"}]}
""".formatted(keyId);
}
private static void assertUnavailable(ThrowingOperation operation) {
assertThatThrownBy(operation::run)
.isInstanceOf(OidcMetadataUnavailableException.class)
.hasMessage("OIDC provider metadata is unavailable")
.hasNoCause();
}
@FunctionalInterface
private interface ThrowingOperation {
void run() throws Exception;
}
private static final class QueueTransport implements OidcSingleHopHttpTransport {
private final Queue<String> responses = new ArrayDeque<>();
private final java.util.List<URI> requests = new java.util.ArrayList<>();
private QueueTransport(String... responses) {
this.responses.addAll(List.of(responses));
}
@Override
public OidcHttpResponse get(URI uri, Duration timeout) {
requests.add(uri);
String body = responses.remove();
return new OidcHttpResponse(
200,
Map.of("Content-Type", List.of("application/json")),
new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))
);
}
}
}

View file

@ -0,0 +1,200 @@
package com.iflytek.skillhub.auth.connection.oidc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.auth.federation.adapter.RemoteIdentityIoExecutor;
import com.nimbusds.jose.jwk.JWKSet;
import java.net.URI;
import java.text.ParseException;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
class OidcMetadataManagerTest {
private static final OidcIssuer ISSUER = new OidcIssuer("https://id.example.com");
private static final Instant NOW = Instant.parse("2026-09-08T06:00:00Z");
@Test
void cacheIsRevisionScopedAndExpiresDeterministically() {
CountingSource source = new CountingSource();
OidcMetadataManager manager = manager(source);
manager.get(key(1), ISSUER, NOW);
manager.get(key(1), ISSUER, NOW.plusSeconds(59));
manager.get(key(2), ISSUER, NOW.plusSeconds(59));
manager.get(key(1), ISSUER, NOW.plusSeconds(61));
assertThat(source.loads).isEqualTo(3);
}
@Test
void productionDefaultsUseBoundedTtlAndKidMissCooldown() {
assertThat(OidcMetadataManager.DEFAULT_TIME_TO_LIVE)
.isEqualTo(Duration.ofMinutes(15));
assertThat(OidcMetadataManager.DEFAULT_KID_MISS_REFRESH_COOLDOWN)
.isEqualTo(Duration.ofSeconds(30));
}
@Test
void kidMissRefreshesOnceAndThenUsesRotatedKey() {
CountingSource source = new CountingSource();
OidcMetadataManager manager = manager(source);
assertThat(manager.resolveKey(key(1), ISSUER, "new-key", NOW).getKeyID())
.isEqualTo("new-key");
assertThat(manager.resolveKey(key(1), ISSUER, "new-key", NOW.plusSeconds(1)).getKeyID())
.isEqualTo("new-key");
assertThat(source.loads).isEqualTo(1);
assertThat(source.keyRefreshes).isEqualTo(1);
}
@Test
void repeatedUnknownKidWithinCooldownCannotTriggerUnboundedRemoteRequests() {
CountingSource source = new CountingSource();
OidcMetadataManager manager = manager(source);
assertThatThrownBy(() -> manager.resolveKey(key(1), ISSUER, "missing-a", NOW))
.isInstanceOf(OidcMetadataUnavailableException.class);
assertThatThrownBy(() -> manager.resolveKey(
key(1), ISSUER, "missing-b", NOW.plusSeconds(1)
)).isInstanceOf(OidcMetadataUnavailableException.class);
assertThat(source.loads).isEqualTo(1);
assertThat(source.keyRefreshes).isEqualTo(1);
}
@Test
void concurrentKidMissesShareOneRemoteRefresh() throws Exception {
CountDownLatch callersReady = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch refreshStarted = new CountDownLatch(1);
CountDownLatch releaseRefresh = new CountDownLatch(1);
AtomicInteger refreshes = new AtomicInteger();
OidcMetadataSource source = new OidcMetadataSource() {
@Override
public OidcMetadataBundle load(OidcIssuer issuer) {
return new OidcMetadataBundle(metadata(), keys("old-key"));
}
@Override
public OidcJwkSetSnapshot refreshKeys(OidcProviderMetadata metadata) {
refreshes.incrementAndGet();
refreshStarted.countDown();
await(releaseRefresh);
return keys("new-key");
}
};
OidcMetadataManager manager = manager(source);
var executor = Executors.newFixedThreadPool(2);
try {
java.util.concurrent.Callable<String> resolve = () -> {
callersReady.countDown();
await(start);
return manager.resolveKey(key(1), ISSUER, "new-key", NOW).getKeyID();
};
Future<String> first = executor.submit(resolve);
Future<String> second = executor.submit(resolve);
assertThat(callersReady.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
assertThat(refreshStarted.await(5, TimeUnit.SECONDS)).isTrue();
releaseRefresh.countDown();
assertThat(first.get(5, TimeUnit.SECONDS)).isEqualTo("new-key");
assertThat(second.get(5, TimeUnit.SECONDS)).isEqualTo("new-key");
assertThat(refreshes).hasValue(1);
} finally {
start.countDown();
releaseRefresh.countDown();
executor.shutdownNow();
}
}
@Test
void invalidatingConnectionDropsAllRevisionEntries() {
CountingSource source = new CountingSource();
OidcMetadataManager manager = manager(source);
manager.get(key(1), ISSUER, NOW);
manager.get(key(2), ISSUER, NOW);
manager.invalidateConnection("organization-1", "connection-1");
manager.get(key(1), ISSUER, NOW.plusSeconds(1));
manager.get(key(2), ISSUER, NOW.plusSeconds(1));
assertThat(source.loads).isEqualTo(4);
}
private static OidcMetadataManager manager(OidcMetadataSource source) {
RemoteIdentityIoExecutor direct = new RemoteIdentityIoExecutor() {
@Override
public <T> T execute(Supplier<T> operation) {
return operation.get();
}
};
return new OidcMetadataManager(
source,
direct,
Duration.ofMinutes(1),
Duration.ofSeconds(30)
);
}
private static OidcMetadataCacheKey key(long revision) {
return new OidcMetadataCacheKey("organization-1", "connection-1", revision);
}
private static final class CountingSource implements OidcMetadataSource {
private int loads;
private int keyRefreshes;
@Override
public OidcMetadataBundle load(OidcIssuer issuer) {
loads++;
return new OidcMetadataBundle(metadata(), keys("old-key"));
}
@Override
public OidcJwkSetSnapshot refreshKeys(OidcProviderMetadata metadata) {
keyRefreshes++;
return keys("new-key");
}
}
private static OidcProviderMetadata metadata() {
return new OidcProviderMetadata(
ISSUER,
URI.create("https://id.example.com/authorize"),
URI.create("https://id.example.com/token"),
URI.create("https://id.example.com/jwks"),
Set.of("RS256")
);
}
private static OidcJwkSetSnapshot keys(String keyId) {
try {
return new OidcJwkSetSnapshot(
JWKSet.parse(DefaultOidcMetadataSourceTest.jwks(keyId)).getKeys()
);
} catch (ParseException exception) {
throw new AssertionError(exception);
}
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Test thread interrupted", exception);
}
}
}

View file

@ -0,0 +1,268 @@
package com.iflytek.skillhub.auth.connection.oidc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import org.junit.jupiter.api.Test;
class SecureOidcDocumentClientTest {
private static final URI PUBLIC_URI = URI.create(
"https://id.example.com/.well-known/openid-configuration"
);
private static final InetAddress PUBLIC_ADDRESS = address("93.184.216.34");
private static final InetAddress PRIVATE_ADDRESS = address("10.0.0.8");
@Test
void derivesDiscoveryUrlAndRejectsNonCanonicalIssuerInputs() {
assertThat(new OidcIssuer("https://id.example.com/tenant/").discoveryUri())
.isEqualTo(URI.create(
"https://id.example.com/tenant/.well-known/openid-configuration"
));
assertThatThrownBy(() -> new OidcIssuer("http://id.example.com"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new OidcIssuer("https://user@id.example.com"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new OidcIssuer("https://ID.example.com"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new OidcIssuer("https://id.example.com?tenant=a"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsPrivateInitialTargetBeforeNetworkIo() {
RecordingTransport transport = new RecordingTransport();
SecureOidcDocumentClient client = client(
host -> List.of(PRIVATE_ADDRESS),
Set.of(),
transport
);
assertUnavailable(() -> client.get(PUBLIC_URI, OidcDocumentKind.METADATA));
assertThat(transport.requestedUris).isEmpty();
}
@Test
void revalidatesRedirectTargetAndBlocksPrivateAddress() {
RecordingTransport transport = new RecordingTransport();
transport.responses.add(response(
302,
Map.of("Location", List.of("https://internal.example.com/metadata")),
new byte[0]
));
OidcHostAddressResolver resolver = host -> host.equals("id.example.com")
? List.of(PUBLIC_ADDRESS)
: List.of(PRIVATE_ADDRESS);
SecureOidcDocumentClient client = client(resolver, Set.of(), transport);
assertUnavailable(() -> client.get(PUBLIC_URI, OidcDocumentKind.METADATA));
assertThat(transport.requestedUris).containsExactly(PUBLIC_URI);
}
@Test
void operatorExactHostAllowlistCanReachPrivateSelfHostedProvider() {
RecordingTransport transport = new RecordingTransport();
transport.responses.add(jsonResponse("{\"issuer\":\"https://id.corp.example\"}"));
SecureOidcDocumentClient client = client(
host -> List.of(PRIVATE_ADDRESS),
Set.of("id.corp.example"),
transport
);
byte[] body = client.get(
URI.create("https://id.corp.example/.well-known/openid-configuration"),
OidcDocumentKind.METADATA
);
assertThat(new String(body, StandardCharsets.UTF_8)).contains("id.corp.example");
}
@Test
void operatorPrivateHostAllowlistRejectsPatternsUrlsAndIpLiterals() {
OidcHostAddressResolver resolver = host -> List.of(PRIVATE_ADDRESS);
assertThatThrownBy(() -> new OidcOutboundTargetPolicy(
resolver,
Set.of("*.corp.example")
)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new OidcOutboundTargetPolicy(
resolver,
Set.of("https://id.corp.example")
)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new OidcOutboundTargetPolicy(
resolver,
Set.of("10.0.0.8")
)).isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsOversizedResponseAndClosesStream() {
TrackingInputStream stream = new TrackingInputStream(
new byte[OidcDocumentKind.METADATA.maxBytes() + 1]
);
RecordingTransport transport = new RecordingTransport();
transport.responses.add(response(
200,
Map.of("Content-Type", List.of("application/json")),
stream
));
SecureOidcDocumentClient client = client(
host -> List.of(PUBLIC_ADDRESS),
Set.of(),
transport
);
assertUnavailable(() -> client.get(PUBLIC_URI, OidcDocumentKind.METADATA));
assertThat(stream.closed).isTrue();
}
@Test
void timeoutFailsClosedWithoutLeakingTransportCause() {
OidcSingleHopHttpTransport transport = (uri, timeout) -> {
assertThat(timeout).isEqualTo(Duration.ofSeconds(5));
throw new HttpTimeoutException("attacker-controlled-host timed out");
};
SecureOidcDocumentClient client = client(
host -> List.of(PUBLIC_ADDRESS),
Set.of(),
transport
);
assertUnavailable(() -> client.get(PUBLIC_URI, OidcDocumentKind.METADATA));
}
@Test
void rejectsMediaTypesThatOnlyShareTheJsonPrefix() {
RecordingTransport transport = new RecordingTransport();
transport.responses.add(response(
200,
Map.of("Content-Type", List.of("application/json-patch+json")),
"{}".getBytes(StandardCharsets.UTF_8)
));
SecureOidcDocumentClient client = client(
host -> List.of(PUBLIC_ADDRESS),
Set.of(),
transport
);
assertUnavailable(() -> client.get(PUBLIC_URI, OidcDocumentKind.METADATA));
}
@Test
void refusesMoreThanThreeRedirects() {
RecordingTransport transport = new RecordingTransport();
for (int index = 1; index <= 4; index++) {
transport.responses.add(response(
302,
Map.of("Location", List.of("https://id.example.com/redirect-" + index)),
new byte[0]
));
}
SecureOidcDocumentClient client = client(
host -> List.of(PUBLIC_ADDRESS),
Set.of(),
transport
);
assertUnavailable(() -> client.get(PUBLIC_URI, OidcDocumentKind.METADATA));
assertThat(transport.requestedUris).hasSize(4);
}
private static SecureOidcDocumentClient client(
OidcHostAddressResolver resolver,
Set<String> allowedPrivateHosts,
OidcSingleHopHttpTransport transport
) {
return new SecureOidcDocumentClient(
new OidcOutboundTargetPolicy(resolver, allowedPrivateHosts),
transport
);
}
private static OidcHttpResponse jsonResponse(String json) {
return response(
200,
Map.of("Content-Type", List.of("application/json; charset=utf-8")),
json.getBytes(StandardCharsets.UTF_8)
);
}
private static OidcHttpResponse response(
int status,
Map<String, List<String>> headers,
byte[] body
) {
return response(status, headers, new ByteArrayInputStream(body));
}
private static OidcHttpResponse response(
int status,
Map<String, List<String>> headers,
ByteArrayInputStream body
) {
return new OidcHttpResponse(status, headers, body);
}
private static InetAddress address(String value) {
try {
return InetAddress.getByName(value);
} catch (IOException exception) {
throw new AssertionError(exception);
}
}
private static void assertUnavailable(ThrowingOperation operation) {
assertThatThrownBy(operation::run)
.isInstanceOf(OidcMetadataUnavailableException.class)
.hasMessage("OIDC provider metadata is unavailable")
.hasNoCause();
}
@FunctionalInterface
private interface ThrowingOperation {
void run();
}
private static final class RecordingTransport implements OidcSingleHopHttpTransport {
private final Queue<OidcHttpResponse> responses = new ArrayDeque<>();
private final List<URI> requestedUris = new ArrayList<>();
@Override
public OidcHttpResponse get(URI uri, Duration timeout) {
requestedUris.add(uri);
OidcHttpResponse response = responses.poll();
if (response == null) {
throw new AssertionError("Unexpected request: " + uri);
}
return response;
}
}
private static final class TrackingInputStream extends ByteArrayInputStream {
private boolean closed;
private TrackingInputStream(byte[] buffer) {
super(buffer);
}
@Override
public void close() throws IOException {
closed = true;
super.close();
}
}
}