From ce768268a78c0ae66ebd3c8a55a4a8359483ba60 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:35:44 +0800 Subject: [PATCH] feat(identity): secure dynamic oidc metadata --- .../oidc/DefaultOidcMetadataSource.java | 149 ++++++++++ .../oidc/JdkOidcSingleHopHttpTransport.java | 49 ++++ .../connection/oidc/OidcDocumentKind.java | 17 ++ .../oidc/OidcHostAddressResolver.java | 16 ++ .../connection/oidc/OidcHttpResponse.java | 30 ++ .../auth/connection/oidc/OidcIssuer.java | 52 ++++ .../connection/oidc/OidcJwkSetSnapshot.java | 50 ++++ .../connection/oidc/OidcMetadataBundle.java | 15 + .../connection/oidc/OidcMetadataCacheKey.java | 24 ++ .../connection/oidc/OidcMetadataManager.java | 176 ++++++++++++ .../connection/oidc/OidcMetadataSource.java | 9 + .../OidcMetadataUnavailableException.java | 9 + .../oidc/OidcOutboundTargetPolicy.java | 130 +++++++++ .../connection/oidc/OidcProviderMetadata.java | 29 ++ .../oidc/OidcSingleHopHttpTransport.java | 11 + .../oidc/SecureOidcDocumentClient.java | 74 +++++ .../auth/connection/oidc/package-info.java | 2 + .../oidc/DefaultOidcMetadataSourceTest.java | 149 ++++++++++ .../oidc/OidcMetadataManagerTest.java | 200 +++++++++++++ .../oidc/SecureOidcDocumentClientTest.java | 268 ++++++++++++++++++ 20 files changed, 1459 insertions(+) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/DefaultOidcMetadataSource.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/JdkOidcSingleHopHttpTransport.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcDocumentKind.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcHostAddressResolver.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcHttpResponse.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcIssuer.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcJwkSetSnapshot.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataBundle.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataCacheKey.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataManager.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataSource.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataUnavailableException.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcOutboundTargetPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcProviderMetadata.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcSingleHopHttpTransport.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/SecureOidcDocumentClient.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/package-info.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/DefaultOidcMetadataSourceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataManagerTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/SecureOidcDocumentClientTest.java diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/DefaultOidcMetadataSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/DefaultOidcMetadataSource.java new file mode 100644 index 00000000..8b0c5181 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/DefaultOidcMetadataSource.java @@ -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 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 responseTypes = stringSet(root, "response_types_supported", 32); + if (!responseTypes.contains("code")) { + throw new OidcMetadataUnavailableException(); + } + Set 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 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 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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/JdkOidcSingleHopHttpTransport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/JdkOidcSingleHopHttpTransport.java new file mode 100644 index 00000000..ffc332c9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/JdkOidcSingleHopHttpTransport.java @@ -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 response = client.send( + request, + HttpResponse.BodyHandlers.ofInputStream() + ); + return new OidcHttpResponse( + response.statusCode(), + response.headers().map(), + response.body() + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcDocumentKind.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcDocumentKind.java new file mode 100644 index 00000000..3b17abf7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcDocumentKind.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcHostAddressResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcHostAddressResolver.java new file mode 100644 index 00000000..48da39dd --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcHostAddressResolver.java @@ -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 resolve(String host) throws UnknownHostException; + + static OidcHostAddressResolver system() { + return host -> List.of(InetAddress.getAllByName(host)); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcHttpResponse.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcHttpResponse.java new file mode 100644 index 00000000..f9945632 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcHttpResponse.java @@ -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> 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(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcIssuer.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcIssuer.java new file mode 100644 index 00000000..36ebd0a7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcIssuer.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcJwkSetSnapshot.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcJwkSetSnapshot.java new file mode 100644 index 00000000..4b870a6d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcJwkSetSnapshot.java @@ -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 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 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 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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataBundle.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataBundle.java new file mode 100644 index 00000000..570d04db --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataBundle.java @@ -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"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataCacheKey.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataCacheKey.java new file mode 100644 index 00000000..32f48e55 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataCacheKey.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataManager.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataManager.java new file mode 100644 index 00000000..cdb8c88d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataManager.java @@ -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 cache = new ConcurrentHashMap<>(); + private final Map 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 safeRemote(java.util.function.Supplier 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 + ) { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataSource.java new file mode 100644 index 00000000..b26d20b1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataSource.java @@ -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); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataUnavailableException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataUnavailableException.java new file mode 100644 index 00000000..763c3bac --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataUnavailableException.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcOutboundTargetPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcOutboundTargetPolicy.java new file mode 100644 index 00000000..57a41476 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcOutboundTargetPolicy.java @@ -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 operatorAllowedPrivateHosts; + + public OidcOutboundTargetPolicy( + OidcHostAddressResolver resolver, + Set 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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcProviderMetadata.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcProviderMetadata.java new file mode 100644 index 00000000..853550ac --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcProviderMetadata.java @@ -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 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" + )); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcSingleHopHttpTransport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcSingleHopHttpTransport.java new file mode 100644 index 00000000..d42fde66 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcSingleHopHttpTransport.java @@ -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; +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/SecureOidcDocumentClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/SecureOidcDocumentClient.java new file mode 100644 index 00000000..5ae20935 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/SecureOidcDocumentClient.java @@ -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 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(); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/package-info.java new file mode 100644 index 00000000..eaf2e2e1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/package-info.java @@ -0,0 +1,2 @@ +/** Dynamic OIDC metadata, JWKS and outbound network security boundaries. */ +package com.iflytek.skillhub.auth.connection.oidc; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/DefaultOidcMetadataSourceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/DefaultOidcMetadataSourceTest.java new file mode 100644 index 00000000..5c121be6 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/DefaultOidcMetadataSourceTest.java @@ -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 responses = new ArrayDeque<>(); + private final java.util.List 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)) + ); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataManagerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataManagerTest.java new file mode 100644 index 00000000..69f89669 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/OidcMetadataManagerTest.java @@ -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 resolve = () -> { + callersReady.countDown(); + await(start); + return manager.resolveKey(key(1), ISSUER, "new-key", NOW).getKeyID(); + }; + Future first = executor.submit(resolve); + Future 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 execute(Supplier 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); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/SecureOidcDocumentClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/SecureOidcDocumentClientTest.java new file mode 100644 index 00000000..f5f262b9 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/SecureOidcDocumentClientTest.java @@ -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 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> headers, + byte[] body + ) { + return response(status, headers, new ByteArrayInputStream(body)); + } + + private static OidcHttpResponse response( + int status, + Map> 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 responses = new ArrayDeque<>(); + private final List 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(); + } + } +}