mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-19 04:53:43 +00:00
feat(identity): secure dynamic oidc metadata
This commit is contained in:
parent
a2a86d3bb6
commit
ce768268a7
20 changed files with 1459 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/** Dynamic OIDC metadata, JWKS and outbound network security boundaries. */
|
||||
package com.iflytek.skillhub.auth.connection.oidc;
|
||||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue