From 4f06e6922457fa39232b850970c19b3f661be2e5 Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Mon, 7 Sep 2026 21:46:47 +0800
Subject: [PATCH 1/2] feat(auth): define enterprise identity contracts
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.../auth/connection/core/AdapterKey.java | 18 ++
.../connection/core/ConnectionHandle.java | 18 ++
.../core/ConnectionUnavailableException.java | 9 +
.../core/EnterpriseConnectionRegistry.java | 8 +
.../connection/core/InteractionModel.java | 14 ++
.../core/LoginConnectionRuntimeConfig.java | 5 +
.../core/LoginConnectionRuntimeSnapshot.java | 50 ++++++
.../auth/connection/core/package-info.java | 2 +
.../core/RedirectAuthenticationAdapter.java | 17 ++
.../core/RedirectCompleteRequest.java | 12 ++
.../core/RedirectRequestValidation.java | 31 ++++
.../federation/core/RedirectStartRequest.java | 28 +++
.../federation/core/RedirectStartResult.java | 11 ++
...rectAuthenticationAdapterContractTest.java | 160 ++++++++++++++++++
14 files changed, 383 insertions(+)
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterKey.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionHandle.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionUnavailableException.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/EnterpriseConnectionRegistry.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/InteractionModel.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/LoginConnectionRuntimeConfig.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/LoginConnectionRuntimeSnapshot.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/package-info.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapter.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectCompleteRequest.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectRequestValidation.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectStartRequest.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectStartResult.java
create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapterContractTest.java
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterKey.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterKey.java
new file mode 100644
index 00000000..aea5b4e2
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterKey.java
@@ -0,0 +1,18 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/** Stable registry key for an authentication adapter implementation. */
+public record AdapterKey(String value) {
+
+ private static final Pattern VALUE_PATTERN = Pattern.compile("[a-z][a-z0-9._-]{0,63}");
+
+ public AdapterKey {
+ Objects.requireNonNull(value, "adapter key must not be null");
+ value = value.trim();
+ if (!VALUE_PATTERN.matcher(value).matches()) {
+ throw new IllegalArgumentException("adapter key must be a normalized stable key");
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionHandle.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionHandle.java
new file mode 100644
index 00000000..e399c9a6
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionHandle.java
@@ -0,0 +1,18 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/** Opaque public handle used to route a login request to an active connection. */
+public record ConnectionHandle(String value) {
+
+ private static final Pattern VALUE_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]{7,127}");
+
+ public ConnectionHandle {
+ Objects.requireNonNull(value, "connection handle must not be null");
+ value = value.trim();
+ if (!VALUE_PATTERN.matcher(value).matches()) {
+ throw new IllegalArgumentException("connection handle must be an opaque stable identifier");
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionUnavailableException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionUnavailableException.java
new file mode 100644
index 00000000..e04e49fd
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionUnavailableException.java
@@ -0,0 +1,9 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+/** Privacy-preserving failure for a missing, inactive or unusable login connection. */
+public final class ConnectionUnavailableException extends RuntimeException {
+
+ public ConnectionUnavailableException() {
+ super("Login connection is unavailable");
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/EnterpriseConnectionRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/EnterpriseConnectionRegistry.java
new file mode 100644
index 00000000..42374c2e
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/EnterpriseConnectionRegistry.java
@@ -0,0 +1,8 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+/** Resolves only validated, active and runtime-compatible login connection snapshots. */
+@FunctionalInterface
+public interface EnterpriseConnectionRegistry {
+
+ LoginConnectionRuntimeSnapshot> requireActive(ConnectionHandle handle);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/InteractionModel.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/InteractionModel.java
new file mode 100644
index 00000000..48d286b9
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/InteractionModel.java
@@ -0,0 +1,14 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+/**
+ * Browser interaction family implemented by a login adapter.
+ *
+ *
Only the redirect family has an executable contract in the first slice. Credential and passive
+ * assertion families are reserved names whose dedicated minimal contracts are deferred until a real
+ * integration requires them.
+ */
+public enum InteractionModel {
+ REDIRECT,
+ CREDENTIAL,
+ PASSIVE_ASSERTION
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/LoginConnectionRuntimeConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/LoginConnectionRuntimeConfig.java
new file mode 100644
index 00000000..11fbdf64
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/LoginConnectionRuntimeConfig.java
@@ -0,0 +1,5 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+/** Marker for immutable, typed and already validated adapter runtime configuration. */
+public interface LoginConnectionRuntimeConfig {
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/LoginConnectionRuntimeSnapshot.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/LoginConnectionRuntimeSnapshot.java
new file mode 100644
index 00000000..68bef7c3
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/LoginConnectionRuntimeSnapshot.java
@@ -0,0 +1,50 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/** Immutable connection revision consumed by the authentication data plane. */
+public record LoginConnectionRuntimeSnapshot(
+ Optional organizationId,
+ String connectionId,
+ ConnectionHandle handle,
+ long revision,
+ AdapterKey adapterKey,
+ int adapterContractVersion,
+ int configSchemaVersion,
+ InteractionModel interactionModel,
+ C config
+) {
+
+ public LoginConnectionRuntimeSnapshot {
+ organizationId = normalizeOptionalText(organizationId, "organizationId");
+ connectionId = requireText(connectionId, "connectionId");
+ Objects.requireNonNull(handle, "connection handle must not be null");
+ if (revision < 1) {
+ throw new IllegalArgumentException("connection revision must be positive");
+ }
+ Objects.requireNonNull(adapterKey, "adapter key must not be null");
+ if (adapterContractVersion < 1) {
+ throw new IllegalArgumentException("adapter contract version must be positive");
+ }
+ if (configSchemaVersion < 1) {
+ throw new IllegalArgumentException("config schema version must be positive");
+ }
+ Objects.requireNonNull(interactionModel, "interaction model must not be null");
+ Objects.requireNonNull(config, "runtime config must not be null");
+ }
+
+ private static Optional normalizeOptionalText(Optional value, String field) {
+ Objects.requireNonNull(value, field + " must not be null");
+ return value.map(text -> requireText(text, field));
+ }
+
+ private static String requireText(String value, String field) {
+ Objects.requireNonNull(value, field + " must not be null");
+ 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/core/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/package-info.java
new file mode 100644
index 00000000..3defaf65
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/package-info.java
@@ -0,0 +1,2 @@
+/** Runtime connection contracts shared by enterprise authentication orchestration and adapters. */
+package com.iflytek.skillhub.auth.connection.core;
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapter.java
new file mode 100644
index 00000000..39c2d472
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapter.java
@@ -0,0 +1,17 @@
+package com.iflytek.skillhub.auth.federation.core;
+
+import com.iflytek.skillhub.auth.connection.core.AdapterKey;
+import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeConfig;
+import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot;
+
+/** Two-phase contract for redirect-based authentication adapters. */
+public interface RedirectAuthenticationAdapter {
+
+ AdapterKey adapterKey();
+
+ Class configType();
+
+ RedirectStartResult start(LoginConnectionRuntimeSnapshot connection, RedirectStartRequest request);
+
+ IdentityAssertion complete(LoginConnectionRuntimeSnapshot connection, RedirectCompleteRequest request);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectCompleteRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectCompleteRequest.java
new file mode 100644
index 00000000..6cc42854
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectCompleteRequest.java
@@ -0,0 +1,12 @@
+package com.iflytek.skillhub.auth.federation.core;
+
+import java.net.URI;
+
+/** Browser transaction and callback response passed to a redirect adapter for protocol verification. */
+public record RedirectCompleteRequest(String browserTransactionId, URI callbackResponseUri) {
+
+ public RedirectCompleteRequest {
+ browserTransactionId = RedirectRequestValidation.requireTransactionId(browserTransactionId);
+ callbackResponseUri = RedirectRequestValidation.requireAbsoluteUri(callbackResponseUri, "callback response URI");
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectRequestValidation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectRequestValidation.java
new file mode 100644
index 00000000..c45636af
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectRequestValidation.java
@@ -0,0 +1,31 @@
+package com.iflytek.skillhub.auth.federation.core;
+
+import java.net.URI;
+import java.util.Objects;
+
+final class RedirectRequestValidation {
+
+ private static final int MAX_TRANSACTION_ID_LENGTH = 512;
+
+ private RedirectRequestValidation() {
+ }
+
+ static String requireTransactionId(String value) {
+ Objects.requireNonNull(value, "browser transaction id must not be null");
+ if (value.isBlank()) {
+ throw new IllegalArgumentException("browser transaction id must not be blank");
+ }
+ if (value.length() > MAX_TRANSACTION_ID_LENGTH || value.codePoints().anyMatch(Character::isISOControl)) {
+ throw new IllegalArgumentException("browser transaction id is invalid");
+ }
+ return value;
+ }
+
+ static URI requireAbsoluteUri(URI value, String field) {
+ Objects.requireNonNull(value, field + " must not be null");
+ if (!value.isAbsolute()) {
+ throw new IllegalArgumentException(field + " must be absolute");
+ }
+ return value;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectStartRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectStartRequest.java
new file mode 100644
index 00000000..82482daf
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectStartRequest.java
@@ -0,0 +1,28 @@
+package com.iflytek.skillhub.auth.federation.core;
+
+import java.net.URI;
+import java.util.Objects;
+import java.util.Optional;
+
+/** Browser transaction inputs used to begin redirect authentication. */
+public record RedirectStartRequest(
+ String browserTransactionId,
+ URI callbackUri,
+ Optional returnTarget
+) {
+
+ public RedirectStartRequest {
+ browserTransactionId = RedirectRequestValidation.requireTransactionId(browserTransactionId);
+ callbackUri = RedirectRequestValidation.requireAbsoluteUri(callbackUri, "callback URI");
+ Objects.requireNonNull(returnTarget, "return target must not be null");
+ returnTarget = returnTarget.map(RedirectStartRequest::requireSiteRelativeTarget);
+ }
+
+ private static String requireSiteRelativeTarget(String value) {
+ Objects.requireNonNull(value, "return target must not be null");
+ if (!value.startsWith("/") || value.startsWith("//") || value.codePoints().anyMatch(Character::isISOControl)) {
+ throw new IllegalArgumentException("return target must be a site-relative path");
+ }
+ return value;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectStartResult.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectStartResult.java
new file mode 100644
index 00000000..b473c070
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectStartResult.java
@@ -0,0 +1,11 @@
+package com.iflytek.skillhub.auth.federation.core;
+
+import java.net.URI;
+
+/** Browser redirect produced by an authentication adapter after start validation. */
+public record RedirectStartResult(URI authorizationUri) {
+
+ public RedirectStartResult {
+ authorizationUri = RedirectRequestValidation.requireAbsoluteUri(authorizationUri, "authorization URI");
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapterContractTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapterContractTest.java
new file mode 100644
index 00000000..e6c449ab
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapterContractTest.java
@@ -0,0 +1,160 @@
+package com.iflytek.skillhub.auth.federation.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.iflytek.skillhub.auth.connection.core.AdapterKey;
+import com.iflytek.skillhub.auth.connection.core.ConnectionHandle;
+import com.iflytek.skillhub.auth.connection.core.ConnectionUnavailableException;
+import com.iflytek.skillhub.auth.connection.core.EnterpriseConnectionRegistry;
+import com.iflytek.skillhub.auth.connection.core.InteractionModel;
+import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeConfig;
+import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+
+class RedirectAuthenticationAdapterContractTest {
+
+ @Test
+ void redirectAdapterProducesVerifiedAssertionThroughTwoPhaseContract() {
+ ConnectionHandle handle = new ConnectionHandle("enterprise-login-a7f9");
+ LoginConnectionRuntimeSnapshot snapshot = new LoginConnectionRuntimeSnapshot<>(
+ Optional.of("org_1"),
+ "connection_1",
+ handle,
+ 3L,
+ new AdapterKey("test-redirect"),
+ 1,
+ 1,
+ InteractionModel.REDIRECT,
+ new TestRuntimeConfig("https://identity.example.com")
+ );
+ EnterpriseConnectionRegistry registry = requested -> {
+ if (!handle.equals(requested)) {
+ throw new ConnectionUnavailableException();
+ }
+ return snapshot;
+ };
+ RedirectAuthenticationAdapter adapter = new TestRedirectAdapter();
+
+ LoginConnectionRuntimeSnapshot> resolved = registry.requireActive(handle);
+ RedirectStartResult started = adapter.start(
+ snapshot,
+ new RedirectStartRequest(
+ "browser-transaction-1",
+ URI.create("https://skillhub.example.com/login/callback"),
+ Optional.of("/dashboard")
+ )
+ );
+ IdentityAssertion assertion = adapter.complete(
+ snapshot,
+ new RedirectCompleteRequest(
+ "browser-transaction-1",
+ URI.create("https://skillhub.example.com/login/callback?code=test&state=opaque")
+ )
+ );
+
+ assertThat(resolved.revision()).isEqualTo(3L);
+ assertThat(started.authorizationUri()).isEqualTo(
+ URI.create("https://identity.example.com/authorize?transaction=browser-transaction-1")
+ );
+ assertThat(assertion.connectionId()).isEqualTo("connection_1");
+ assertThat(assertion.subject().value()).isEqualTo("subject-123");
+ }
+
+ @Test
+ void registryFailsClosedForUnknownOrInactiveConnection() {
+ EnterpriseConnectionRegistry registry = handle -> {
+ throw new ConnectionUnavailableException();
+ };
+
+ assertThatThrownBy(() -> registry.requireActive(new ConnectionHandle("unknown-handle")))
+ .isInstanceOf(ConnectionUnavailableException.class);
+ }
+
+ @Test
+ void interactionModelsNameDeferredCredentialAndPassiveFamiliesWithoutExpandingRedirectContract() {
+ assertThat(InteractionModel.values())
+ .containsExactly(
+ InteractionModel.REDIRECT,
+ InteractionModel.CREDENTIAL,
+ InteractionModel.PASSIVE_ASSERTION
+ );
+ assertThat(RedirectAuthenticationAdapter.class.getDeclaredMethods())
+ .extracting(Method::getName)
+ .containsExactlyInAnyOrder("adapterKey", "configType", "start", "complete");
+ }
+
+ @Test
+ void adapterContractExposesNoPlatformStateOrRepositoryTypes() {
+ Set forbiddenTypeFragments = Set.of(
+ "UserAccount",
+ "OrganizationMembership",
+ "NamespaceMember",
+ "PlatformPrincipal",
+ "Repository"
+ );
+
+ Stream> exposedTypes = Arrays.stream(RedirectAuthenticationAdapter.class.getDeclaredMethods())
+ .flatMap(method -> Stream.concat(
+ Stream.of(method.getReturnType()),
+ Arrays.stream(method.getParameterTypes())
+ ));
+
+ assertThat(exposedTypes.map(Class::getName))
+ .noneMatch(name -> forbiddenTypeFragments.stream().anyMatch(name::contains));
+ }
+
+ private record TestRuntimeConfig(String issuer) implements LoginConnectionRuntimeConfig {
+ }
+
+ private static final class TestRedirectAdapter implements RedirectAuthenticationAdapter {
+
+ @Override
+ public AdapterKey adapterKey() {
+ return new AdapterKey("test-redirect");
+ }
+
+ @Override
+ public Class configType() {
+ return TestRuntimeConfig.class;
+ }
+
+ @Override
+ public RedirectStartResult start(
+ LoginConnectionRuntimeSnapshot connection,
+ RedirectStartRequest request
+ ) {
+ return new RedirectStartResult(URI.create(
+ connection.config().issuer() + "/authorize?transaction=" + request.browserTransactionId()
+ ));
+ }
+
+ @Override
+ public IdentityAssertion complete(
+ LoginConnectionRuntimeSnapshot connection,
+ RedirectCompleteRequest request
+ ) {
+ return new IdentityAssertion(
+ connection.organizationId(),
+ connection.connectionId(),
+ URI.create(connection.config().issuer()),
+ new SubjectRef(new SubjectType("opaque-user-id"), "subject-123"),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Set.of(new Assurance("single-factor")),
+ Instant.parse("2026-09-07T12:00:00Z"),
+ Map.of()
+ );
+ }
+ }
+}
From 17da5d1e3f72fde177dd31ba7a651091a8bf1b2b Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Mon, 7 Sep 2026 23:12:58 +0800
Subject: [PATCH 2/2] feat(auth): validate built-in adapter contracts
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.../check-enterprise-identity-architecture.sh | 171 ++++++++++++
.../connection/core/AdapterCapability.java | 12 +
.../core/AdapterContractVersion.java | 14 +
.../connection/core/AdapterDescriptor.java | 27 ++
.../core/AdapterDescriptorRegistry.java | 189 ++++++++++++++
.../core/AdapterRegistryException.java | 18 ++
.../core/AdapterRegistryFailureReason.java | 13 +
.../auth/connection/core/ConnectionKind.java | 7 +
...RedirectAuthenticationAdapterRegistry.java | 70 +++++
.../core/RedirectAuthenticationAdapter.java | 4 +-
.../core/AdapterDescriptorRegistryTest.java | 243 ++++++++++++++++++
...rectAuthenticationAdapterRegistryTest.java | 88 +++++++
...rectAuthenticationAdapterContractTest.java | 17 +-
13 files changed, 868 insertions(+), 5 deletions(-)
create mode 100755 scripts/check-enterprise-identity-architecture.sh
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterCapability.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterContractVersion.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptor.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptorRegistry.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterRegistryException.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterRegistryFailureReason.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionKind.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/BuiltInRedirectAuthenticationAdapterRegistry.java
create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptorRegistryTest.java
create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/BuiltInRedirectAuthenticationAdapterRegistryTest.java
diff --git a/scripts/check-enterprise-identity-architecture.sh b/scripts/check-enterprise-identity-architecture.sh
new file mode 100755
index 00000000..8e9945b6
--- /dev/null
+++ b/scripts/check-enterprise-identity-architecture.sh
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repository_root="$(cd "$script_dir/.." && pwd)"
+
+require_rg() {
+ if ! command -v rg >/dev/null 2>&1; then
+ echo "enterprise identity architecture check requires rg" >&2
+ exit 2
+ fi
+}
+
+report_matches() {
+ local label="$1"
+ local directory="$2"
+ local pattern="$3"
+ local matches
+
+ if [[ ! -d "$directory" ]]; then
+ return 0
+ fi
+
+ matches="$(rg -n --glob '*.java' --regexp "$pattern" "$directory" || true)"
+ if [[ -z "$matches" ]]; then
+ return 0
+ fi
+
+ echo "[$label]" >&2
+ echo "$matches" >&2
+ return 1
+}
+
+check_tree() {
+ local root="$1"
+ local violations=0
+ local domain_root="$root/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain"
+ local auth_root="$root/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth"
+ local protected_domain
+ local protected_core
+ local adapter_root
+
+ for protected_domain in organization directory entitlement; do
+ report_matches \
+ "domain-$protected_domain-forbidden-dependency" \
+ "$domain_root/$protected_domain" \
+ '^import com\.iflytek\.skillhub\.(auth|controller|compat|infra|repository|service|scim|oauth)\.' \
+ || violations=$((violations + 1))
+ report_matches \
+ "domain-$protected_domain-protocol-leak" \
+ "$domain_root/$protected_domain" \
+ '(?i)\b(oidc|oauth|saml|scim|ldap|dingtalk|okta|azure|github|gitlab)\b' \
+ || violations=$((violations + 1))
+ done
+
+ for protected_core in federation/core identity/core connection/core; do
+ report_matches \
+ "identity-core-concrete-adapter-import" \
+ "$auth_root/$protected_core" \
+ '^import .*\.adapter\.' \
+ || violations=$((violations + 1))
+ report_matches \
+ "identity-core-provider-branch" \
+ "$auth_root/$protected_core" \
+ '(?i)\b(github|gitlab|dingtalk|okta|azure|keycloak|auth0)\b' \
+ || violations=$((violations + 1))
+ done
+
+ for adapter_root in \
+ "$auth_root/federation/adapter" \
+ "$auth_root/connection/adapter"; do
+ report_matches \
+ "adapter-direct-state-write" \
+ "$adapter_root" \
+ '^import .*\.(UserAccountRepository|OrganizationMembershipRepository|NamespaceMemberRepository|NamespaceMemberGrantRepository);' \
+ || violations=$((violations + 1))
+ report_matches \
+ "adapter-runtime-code-loading" \
+ "$adapter_root" \
+ '(Class\.forName|URLClassLoader|ScriptEngineManager)' \
+ || violations=$((violations + 1))
+ done
+
+ if (( violations > 0 )); then
+ echo "enterprise identity architecture violations: $violations" >&2
+ return 1
+ fi
+}
+
+self_test() {
+ local fixture_root
+ local violation_output
+ fixture_root="$(mktemp -d -t skillhub-identity-architecture.XXXXXX)"
+
+ cleanup_fixture() {
+ if [[ -n "${fixture_root:-}" && -d "$fixture_root" && "$fixture_root" == /tmp/skillhub-identity-architecture.* ]]; then
+ rm -rf -- "$fixture_root"
+ fi
+ }
+ trap cleanup_fixture EXIT
+
+ mkdir -p \
+ "$fixture_root/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization" \
+ "$fixture_root/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/core" \
+ "$fixture_root/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter"
+
+ printf '%s\n' \
+ 'package com.iflytek.skillhub.domain.organization;' \
+ 'public record Organization(String id) {}' \
+ > "$fixture_root/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/Organization.java"
+ printf '%s\n' \
+ 'package com.iflytek.skillhub.auth.identity.core;' \
+ 'public final class IdentityDecision {}' \
+ > "$fixture_root/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/core/IdentityDecision.java"
+
+ if ! check_tree "$fixture_root" >/dev/null 2>&1; then
+ echo "architecture self-test rejected the approved fixture" >&2
+ return 1
+ fi
+
+ printf '%s\n' \
+ 'package com.iflytek.skillhub.domain.organization;' \
+ 'import com.iflytek.skillhub.auth.identity.IdentityBindingService;' \
+ 'public final class ForbiddenDomainDependency {}' \
+ > "$fixture_root/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/ForbiddenDomainDependency.java"
+ printf '%s\n' \
+ 'package com.iflytek.skillhub.auth.identity.core;' \
+ 'public final class ProviderBranch { String provider = "github"; }' \
+ > "$fixture_root/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/core/ProviderBranch.java"
+ printf '%s\n' \
+ 'package com.iflytek.skillhub.auth.federation.adapter;' \
+ 'import com.iflytek.skillhub.domain.user.UserAccountRepository;' \
+ 'public final class DirectStateWrite { void load() throws Exception { Class.forName("evil.Plugin"); } }' \
+ > "$fixture_root/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/DirectStateWrite.java"
+ printf '%s\n' \
+ 'package com.iflytek.skillhub.domain.organization;' \
+ 'public final class ScimWirePayload { String protocol = "SCIM"; }' \
+ > "$fixture_root/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/ScimWirePayload.java"
+
+ if violation_output="$(check_tree "$fixture_root" 2>&1)"; then
+ echo "architecture self-test accepted intentional violations" >&2
+ return 1
+ fi
+
+ for expected in \
+ domain-organization-forbidden-dependency \
+ domain-organization-protocol-leak \
+ identity-core-provider-branch \
+ adapter-direct-state-write \
+ adapter-runtime-code-loading; do
+ if [[ "$violation_output" != *"[$expected]"* ]]; then
+ echo "architecture self-test did not detect $expected" >&2
+ return 1
+ fi
+ done
+
+ echo "enterprise identity architecture self-test passed"
+}
+
+require_rg
+if [[ "${1:-}" == "--self-test" ]]; then
+ self_test
+ exit 0
+fi
+if [[ $# -ne 0 ]]; then
+ echo "usage: $0 [--self-test]" >&2
+ exit 2
+fi
+
+check_tree "$repository_root"
+echo "enterprise identity architecture check passed"
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterCapability.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterCapability.java
new file mode 100644
index 00000000..e4ec615a
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterCapability.java
@@ -0,0 +1,12 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+/** Platform-understood behavior that an adapter may safely advertise. */
+public enum AdapterCapability {
+ IDENTITY_ASSERTION,
+ VERIFIED_EMAIL_ASSERTION,
+ PROFILE_ATTRIBUTE_ASSERTION,
+ DIRECTORY_USERS,
+ DIRECTORY_GROUPS,
+ INCREMENTAL_RECONCILIATION,
+ DEPROVISIONING
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterContractVersion.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterContractVersion.java
new file mode 100644
index 00000000..8dc4e378
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterContractVersion.java
@@ -0,0 +1,14 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+/** Explicit adapter contract version; only major changes may break persisted revisions. */
+public record AdapterContractVersion(int major, int minor) {
+
+ public AdapterContractVersion {
+ if (major < 1) {
+ throw new IllegalArgumentException("adapter contract major version must be positive");
+ }
+ if (minor < 0) {
+ throw new IllegalArgumentException("adapter contract minor version must not be negative");
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptor.java
new file mode 100644
index 00000000..988875b1
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptor.java
@@ -0,0 +1,27 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/** Persistable adapter identity and platform capabilities, without implementation class names. */
+public record AdapterDescriptor(
+ AdapterKey adapterKey,
+ AdapterContractVersion contractVersion,
+ ConnectionKind connectionKind,
+ int configSchemaVersion,
+ Optional interactionModel,
+ Set capabilities
+) {
+
+ public AdapterDescriptor {
+ Objects.requireNonNull(adapterKey, "adapterKey");
+ Objects.requireNonNull(contractVersion, "contractVersion");
+ Objects.requireNonNull(connectionKind, "connectionKind");
+ if (configSchemaVersion < 1) {
+ throw new IllegalArgumentException("config schema version must be positive");
+ }
+ interactionModel = Objects.requireNonNull(interactionModel, "interactionModel");
+ capabilities = Set.copyOf(Objects.requireNonNull(capabilities, "capabilities"));
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptorRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptorRegistry.java
new file mode 100644
index 00000000..caaa6a34
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptorRegistry.java
@@ -0,0 +1,189 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/** Validated catalog of built-in descriptors keyed by stable adapter key, contract and schema version. */
+public final class AdapterDescriptorRegistry {
+
+ private static final Set SUPPORTED_CONTRACT_MAJORS = Set.of(1);
+ private static final Set LOGIN_CAPABILITIES = Set.of(
+ AdapterCapability.IDENTITY_ASSERTION,
+ AdapterCapability.VERIFIED_EMAIL_ASSERTION,
+ AdapterCapability.PROFILE_ATTRIBUTE_ASSERTION
+ );
+ private static final Set DIRECTORY_CAPABILITIES = Set.of(
+ AdapterCapability.DIRECTORY_USERS,
+ AdapterCapability.DIRECTORY_GROUPS,
+ AdapterCapability.INCREMENTAL_RECONCILIATION,
+ AdapterCapability.DEPROVISIONING
+ );
+
+ private final Map descriptorsByKey;
+ private final List descriptors;
+
+ public AdapterDescriptorRegistry(Collection descriptors) {
+ Objects.requireNonNull(descriptors, "descriptors");
+ List ordered = new ArrayList<>(descriptors);
+ ordered.forEach(descriptor -> Objects.requireNonNull(descriptor, "descriptor"));
+ ordered.sort(Comparator
+ .comparing((AdapterDescriptor descriptor) -> descriptor.adapterKey().value())
+ .thenComparingInt(descriptor -> descriptor.contractVersion().major())
+ .thenComparingInt(descriptor -> descriptor.contractVersion().minor())
+ .thenComparingInt(AdapterDescriptor::configSchemaVersion));
+
+ Map validated = new LinkedHashMap<>();
+ for (AdapterDescriptor descriptor : ordered) {
+ validate(descriptor);
+ RegistrationKey key = RegistrationKey.from(descriptor);
+ if (validated.putIfAbsent(key, descriptor) != null) {
+ throw failure(
+ AdapterRegistryFailureReason.DUPLICATE_REGISTRATION,
+ descriptor,
+ "duplicate adapter key, contract version and config schema version"
+ );
+ }
+ }
+ this.descriptorsByKey = Map.copyOf(validated);
+ this.descriptors = List.copyOf(ordered);
+ }
+
+ public AdapterDescriptor require(
+ AdapterKey adapterKey,
+ AdapterContractVersion contractVersion,
+ int configSchemaVersion
+ ) {
+ Objects.requireNonNull(adapterKey, "adapterKey");
+ Objects.requireNonNull(contractVersion, "contractVersion");
+ if (configSchemaVersion < 1) {
+ throw new IllegalArgumentException("config schema version must be positive");
+ }
+ AdapterDescriptor descriptor = descriptorsByKey.get(new RegistrationKey(
+ adapterKey,
+ contractVersion,
+ configSchemaVersion
+ ));
+ if (descriptor == null) {
+ boolean sameContract = descriptors.stream().anyMatch(candidate ->
+ candidate.adapterKey().equals(adapterKey)
+ && candidate.contractVersion().equals(contractVersion));
+ if (sameContract) {
+ throw new AdapterRegistryException(
+ AdapterRegistryFailureReason.UNSUPPORTED_CONFIG_SCHEMA_VERSION,
+ "Adapter is not registered for the requested config schema version"
+ );
+ }
+ boolean sameKey = descriptors.stream().anyMatch(candidate ->
+ candidate.adapterKey().equals(adapterKey));
+ if (sameKey) {
+ throw new AdapterRegistryException(
+ AdapterRegistryFailureReason.UNSUPPORTED_CONTRACT_VERSION,
+ "Adapter is not registered for the requested contract version"
+ );
+ }
+ throw new AdapterRegistryException(
+ AdapterRegistryFailureReason.ADAPTER_NOT_REGISTERED,
+ "Adapter is not registered for the requested key"
+ );
+ }
+ return descriptor;
+ }
+
+ public List descriptors() {
+ return descriptors;
+ }
+
+ private static void validate(AdapterDescriptor descriptor) {
+ if (!SUPPORTED_CONTRACT_MAJORS.contains(descriptor.contractVersion().major())) {
+ throw failure(
+ AdapterRegistryFailureReason.UNSUPPORTED_CONTRACT_VERSION,
+ descriptor,
+ "unsupported adapter contract major"
+ );
+ }
+ switch (descriptor.connectionKind()) {
+ case LOGIN -> validateLogin(descriptor);
+ case DIRECTORY -> validateDirectory(descriptor);
+ }
+ }
+
+ private static void validateLogin(AdapterDescriptor descriptor) {
+ if (descriptor.interactionModel().isEmpty()) {
+ throw failure(
+ AdapterRegistryFailureReason.INVALID_INTERACTION_MODEL,
+ descriptor,
+ "login adapter must declare an interaction model"
+ );
+ }
+ if (!LOGIN_CAPABILITIES.containsAll(descriptor.capabilities())) {
+ throw failure(
+ AdapterRegistryFailureReason.INVALID_CAPABILITY_SET,
+ descriptor,
+ "login adapter declares a non-login capability"
+ );
+ }
+ if (!descriptor.capabilities().contains(AdapterCapability.IDENTITY_ASSERTION)) {
+ throw failure(
+ AdapterRegistryFailureReason.MISSING_REQUIRED_CAPABILITY,
+ descriptor,
+ "login adapter must emit an identity assertion"
+ );
+ }
+ }
+
+ private static void validateDirectory(AdapterDescriptor descriptor) {
+ if (descriptor.interactionModel().isPresent()) {
+ throw failure(
+ AdapterRegistryFailureReason.INVALID_INTERACTION_MODEL,
+ descriptor,
+ "directory adapter must not declare a login interaction model"
+ );
+ }
+ if (!DIRECTORY_CAPABILITIES.containsAll(descriptor.capabilities())) {
+ throw failure(
+ AdapterRegistryFailureReason.INVALID_CAPABILITY_SET,
+ descriptor,
+ "directory adapter declares a non-directory capability"
+ );
+ }
+ if (!descriptor.capabilities().contains(AdapterCapability.DIRECTORY_USERS)) {
+ throw failure(
+ AdapterRegistryFailureReason.MISSING_REQUIRED_CAPABILITY,
+ descriptor,
+ "directory capabilities require directory user provisioning"
+ );
+ }
+ }
+
+ private static AdapterRegistryException failure(
+ AdapterRegistryFailureReason reason,
+ AdapterDescriptor descriptor,
+ String detail
+ ) {
+ return new AdapterRegistryException(
+ reason,
+ detail + ": " + descriptor.adapterKey().value()
+ );
+ }
+
+ private record RegistrationKey(
+ AdapterKey adapterKey,
+ AdapterContractVersion contractVersion,
+ int configSchemaVersion
+ ) {
+
+ private static RegistrationKey from(AdapterDescriptor descriptor) {
+ return new RegistrationKey(
+ descriptor.adapterKey(),
+ descriptor.contractVersion(),
+ descriptor.configSchemaVersion()
+ );
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterRegistryException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterRegistryException.java
new file mode 100644
index 00000000..40502109
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterRegistryException.java
@@ -0,0 +1,18 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+import java.util.Objects;
+
+/** Deterministic startup or lookup failure for the built-in adapter registry. */
+public final class AdapterRegistryException extends IllegalStateException {
+
+ private final AdapterRegistryFailureReason reason;
+
+ public AdapterRegistryException(AdapterRegistryFailureReason reason, String message) {
+ super(message);
+ this.reason = Objects.requireNonNull(reason, "reason");
+ }
+
+ public AdapterRegistryFailureReason reason() {
+ return reason;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterRegistryFailureReason.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterRegistryFailureReason.java
new file mode 100644
index 00000000..cb2734fb
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/AdapterRegistryFailureReason.java
@@ -0,0 +1,13 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+/** Stable failure categories for adapter registration and lookup. */
+public enum AdapterRegistryFailureReason {
+ DUPLICATE_REGISTRATION,
+ UNSUPPORTED_CONTRACT_VERSION,
+ UNSUPPORTED_CONFIG_SCHEMA_VERSION,
+ INVALID_INTERACTION_MODEL,
+ INVALID_CAPABILITY_SET,
+ MISSING_REQUIRED_CAPABILITY,
+ IMPLEMENTATION_MISMATCH,
+ ADAPTER_NOT_REGISTERED
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionKind.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionKind.java
new file mode 100644
index 00000000..7068cdb5
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/core/ConnectionKind.java
@@ -0,0 +1,7 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+/** Independent connection planes understood by the platform registry. */
+public enum ConnectionKind {
+ LOGIN,
+ DIRECTORY
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/BuiltInRedirectAuthenticationAdapterRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/BuiltInRedirectAuthenticationAdapterRegistry.java
new file mode 100644
index 00000000..0824e916
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/BuiltInRedirectAuthenticationAdapterRegistry.java
@@ -0,0 +1,70 @@
+package com.iflytek.skillhub.auth.federation.adapter;
+
+import com.iflytek.skillhub.auth.connection.core.AdapterDescriptor;
+import com.iflytek.skillhub.auth.connection.core.AdapterDescriptorRegistry;
+import com.iflytek.skillhub.auth.connection.core.AdapterContractVersion;
+import com.iflytek.skillhub.auth.connection.core.AdapterKey;
+import com.iflytek.skillhub.auth.connection.core.AdapterRegistryException;
+import com.iflytek.skillhub.auth.connection.core.AdapterRegistryFailureReason;
+import com.iflytek.skillhub.auth.connection.core.ConnectionKind;
+import com.iflytek.skillhub.auth.connection.core.InteractionModel;
+import com.iflytek.skillhub.auth.federation.core.RedirectAuthenticationAdapter;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/** Registry of reviewed redirect adapter instances assembled by the application build. */
+public final class BuiltInRedirectAuthenticationAdapterRegistry {
+
+ private final AdapterDescriptorRegistry descriptors;
+ private final Map> adapters;
+
+ public BuiltInRedirectAuthenticationAdapterRegistry(
+ Collection extends RedirectAuthenticationAdapter>> adapters
+ ) {
+ Objects.requireNonNull(adapters, "adapters");
+ this.descriptors = new AdapterDescriptorRegistry(
+ adapters.stream().map(RedirectAuthenticationAdapter::descriptor).toList()
+ );
+ Map> validated = new LinkedHashMap<>();
+ for (RedirectAuthenticationAdapter> adapter : adapters) {
+ Objects.requireNonNull(adapter, "adapter");
+ AdapterDescriptor descriptor = adapter.descriptor();
+ if (descriptor.connectionKind() != ConnectionKind.LOGIN
+ || descriptor.interactionModel().orElse(null) != InteractionModel.REDIRECT) {
+ throw new AdapterRegistryException(
+ AdapterRegistryFailureReason.IMPLEMENTATION_MISMATCH,
+ "Redirect adapter descriptor does not declare a redirect login interaction"
+ );
+ }
+ Objects.requireNonNull(adapter.configType(), "adapter configType");
+ validated.put(RegistrationKey.from(descriptor), adapter);
+ }
+ this.adapters = Map.copyOf(validated);
+ }
+
+ public RedirectAuthenticationAdapter> require(
+ AdapterKey adapterKey,
+ AdapterContractVersion contractVersion,
+ int configSchemaVersion
+ ) {
+ descriptors.require(adapterKey, contractVersion, configSchemaVersion);
+ return adapters.get(new RegistrationKey(adapterKey, contractVersion, configSchemaVersion));
+ }
+
+ private record RegistrationKey(
+ AdapterKey adapterKey,
+ AdapterContractVersion contractVersion,
+ int configSchemaVersion
+ ) {
+
+ private static RegistrationKey from(AdapterDescriptor descriptor) {
+ return new RegistrationKey(
+ descriptor.adapterKey(),
+ descriptor.contractVersion(),
+ descriptor.configSchemaVersion()
+ );
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapter.java
index 39c2d472..90491df4 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapter.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapter.java
@@ -1,13 +1,13 @@
package com.iflytek.skillhub.auth.federation.core;
-import com.iflytek.skillhub.auth.connection.core.AdapterKey;
+import com.iflytek.skillhub.auth.connection.core.AdapterDescriptor;
import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeConfig;
import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot;
/** Two-phase contract for redirect-based authentication adapters. */
public interface RedirectAuthenticationAdapter {
- AdapterKey adapterKey();
+ AdapterDescriptor descriptor();
Class configType();
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptorRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptorRegistryTest.java
new file mode 100644
index 00000000..c51d9484
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/core/AdapterDescriptorRegistryTest.java
@@ -0,0 +1,243 @@
+package com.iflytek.skillhub.auth.connection.core;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AdapterDescriptorRegistryTest {
+
+ @Test
+ void require_resolvesVersionedBuiltInLoginAndDirectoryDescriptors() {
+ AdapterDescriptor login = loginDescriptor(
+ "oidc-standard",
+ new AdapterContractVersion(1, 2),
+ InteractionModel.REDIRECT,
+ Set.of(
+ AdapterCapability.IDENTITY_ASSERTION,
+ AdapterCapability.VERIFIED_EMAIL_ASSERTION
+ )
+ );
+ AdapterDescriptor directory = new AdapterDescriptor(
+ new AdapterKey("scim-directory"),
+ new AdapterContractVersion(1, 0),
+ ConnectionKind.DIRECTORY,
+ 1,
+ Optional.empty(),
+ Set.of(
+ AdapterCapability.DIRECTORY_USERS,
+ AdapterCapability.DIRECTORY_GROUPS,
+ AdapterCapability.DEPROVISIONING
+ )
+ );
+ AdapterDescriptorRegistry registry = new AdapterDescriptorRegistry(List.of(login, directory));
+
+ assertThat(registry.require(new AdapterKey("oidc-standard"), new AdapterContractVersion(1, 2), 1))
+ .isSameAs(login);
+ assertThat(registry.require(new AdapterKey("scim-directory"), new AdapterContractVersion(1, 0), 1))
+ .isSameAs(directory);
+ assertThat(registry.descriptors()).containsExactly(login, directory);
+ }
+
+ @Test
+ void constructor_rejectsDuplicateAdapterKeyContractVersionAndConfigSchemaVersion() {
+ AdapterDescriptor first = loginDescriptor(
+ "duplicate-login",
+ new AdapterContractVersion(1, 0),
+ InteractionModel.REDIRECT,
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+ AdapterDescriptor second = loginDescriptor(
+ "duplicate-login",
+ new AdapterContractVersion(1, 0),
+ InteractionModel.REDIRECT,
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+
+ assertRegistryFailure(
+ () -> new AdapterDescriptorRegistry(List.of(second, first)),
+ AdapterRegistryFailureReason.DUPLICATE_REGISTRATION
+ );
+ }
+
+ @Test
+ void constructor_allowsSameAdapterKeyWithDifferentMinorOrConfigSchemaVersion() {
+ AdapterDescriptor first = loginDescriptor(
+ "versioned-login",
+ new AdapterContractVersion(1, 0),
+ InteractionModel.REDIRECT,
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+ AdapterDescriptor second = new AdapterDescriptor(
+ new AdapterKey("versioned-login"),
+ new AdapterContractVersion(1, 1),
+ ConnectionKind.LOGIN,
+ 2,
+ Optional.of(InteractionModel.REDIRECT),
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+
+ AdapterDescriptorRegistry registry = new AdapterDescriptorRegistry(List.of(second, first));
+
+ assertThat(registry.require(new AdapterKey("versioned-login"), new AdapterContractVersion(1, 0), 1))
+ .isSameAs(first);
+ assertThat(registry.require(new AdapterKey("versioned-login"), new AdapterContractVersion(1, 1), 2))
+ .isSameAs(second);
+ }
+
+ @Test
+ void constructor_rejectsUnsupportedContractMajorVersion() {
+ AdapterDescriptor future = loginDescriptor(
+ "future-login",
+ new AdapterContractVersion(2, 0),
+ InteractionModel.REDIRECT,
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+
+ assertRegistryFailure(
+ () -> new AdapterDescriptorRegistry(List.of(future)),
+ AdapterRegistryFailureReason.UNSUPPORTED_CONTRACT_VERSION
+ );
+ }
+
+ @Test
+ void constructor_rejectsLoginWithoutInteractionModel() {
+ AdapterDescriptor invalid = new AdapterDescriptor(
+ new AdapterKey("missing-interaction"),
+ new AdapterContractVersion(1, 0),
+ ConnectionKind.LOGIN,
+ 1,
+ Optional.empty(),
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+
+ assertRegistryFailure(
+ () -> new AdapterDescriptorRegistry(List.of(invalid)),
+ AdapterRegistryFailureReason.INVALID_INTERACTION_MODEL
+ );
+ }
+
+ @Test
+ void constructor_rejectsDirectoryWithLoginInteractionModel() {
+ AdapterDescriptor invalid = new AdapterDescriptor(
+ new AdapterKey("directory-redirect"),
+ new AdapterContractVersion(1, 0),
+ ConnectionKind.DIRECTORY,
+ 1,
+ Optional.of(InteractionModel.REDIRECT),
+ Set.of(AdapterCapability.DIRECTORY_USERS)
+ );
+
+ assertRegistryFailure(
+ () -> new AdapterDescriptorRegistry(List.of(invalid)),
+ AdapterRegistryFailureReason.INVALID_INTERACTION_MODEL
+ );
+ }
+
+ @Test
+ void constructor_rejectsCapabilitiesFromAnotherConnectionKind() {
+ AdapterDescriptor invalid = loginDescriptor(
+ "login-with-directory-write",
+ new AdapterContractVersion(1, 0),
+ InteractionModel.PASSIVE_ASSERTION,
+ Set.of(
+ AdapterCapability.IDENTITY_ASSERTION,
+ AdapterCapability.DIRECTORY_USERS
+ )
+ );
+
+ assertRegistryFailure(
+ () -> new AdapterDescriptorRegistry(List.of(invalid)),
+ AdapterRegistryFailureReason.INVALID_CAPABILITY_SET
+ );
+ }
+
+ @Test
+ void constructor_rejectsCapabilitiesWhoseDependenciesAreMissing() {
+ AdapterDescriptor invalid = new AdapterDescriptor(
+ new AdapterKey("groups-without-users"),
+ new AdapterContractVersion(1, 0),
+ ConnectionKind.DIRECTORY,
+ 1,
+ Optional.empty(),
+ Set.of(AdapterCapability.DIRECTORY_GROUPS)
+ );
+
+ assertRegistryFailure(
+ () -> new AdapterDescriptorRegistry(List.of(invalid)),
+ AdapterRegistryFailureReason.MISSING_REQUIRED_CAPABILITY
+ );
+ }
+
+ @Test
+ void require_failsClosedWhenRequestedContractMinorIsNotRegistered() {
+ AdapterDescriptor descriptor = loginDescriptor(
+ "oidc-standard",
+ new AdapterContractVersion(1, 2),
+ InteractionModel.REDIRECT,
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+ AdapterDescriptorRegistry registry = new AdapterDescriptorRegistry(List.of(descriptor));
+
+ assertRegistryFailure(
+ () -> registry.require(new AdapterKey("oidc-standard"), new AdapterContractVersion(1, 0), 1),
+ AdapterRegistryFailureReason.UNSUPPORTED_CONTRACT_VERSION
+ );
+ }
+
+ @Test
+ void require_failsClosedWhenRequestedConfigSchemaVersionIsNotRegistered() {
+ AdapterDescriptor descriptor = loginDescriptor(
+ "oidc-standard",
+ new AdapterContractVersion(1, 2),
+ InteractionModel.REDIRECT,
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+ AdapterDescriptorRegistry registry = new AdapterDescriptorRegistry(List.of(descriptor));
+
+ assertRegistryFailure(
+ () -> registry.require(new AdapterKey("oidc-standard"), new AdapterContractVersion(1, 2), 2),
+ AdapterRegistryFailureReason.UNSUPPORTED_CONFIG_SCHEMA_VERSION
+ );
+ }
+
+ @Test
+ void require_failsClosedForUnknownStableKeyWithoutLoadingAClassName() {
+ AdapterDescriptorRegistry registry = new AdapterDescriptorRegistry(List.of());
+
+ assertRegistryFailure(
+ () -> registry.require(new AdapterKey("database-class-name"), new AdapterContractVersion(1, 0), 1),
+ AdapterRegistryFailureReason.ADAPTER_NOT_REGISTERED
+ );
+ }
+
+ private static AdapterDescriptor loginDescriptor(
+ String key,
+ AdapterContractVersion contractVersion,
+ InteractionModel interactionModel,
+ Set capabilities
+ ) {
+ return new AdapterDescriptor(
+ new AdapterKey(key),
+ contractVersion,
+ ConnectionKind.LOGIN,
+ 1,
+ Optional.of(interactionModel),
+ capabilities
+ );
+ }
+
+ private static void assertRegistryFailure(
+ Runnable operation,
+ AdapterRegistryFailureReason reason
+ ) {
+ assertThatThrownBy(operation::run)
+ .isInstanceOfSatisfying(
+ AdapterRegistryException.class,
+ failure -> assertThat(failure.reason()).isEqualTo(reason)
+ );
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/BuiltInRedirectAuthenticationAdapterRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/BuiltInRedirectAuthenticationAdapterRegistryTest.java
new file mode 100644
index 00000000..5b0abd42
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/BuiltInRedirectAuthenticationAdapterRegistryTest.java
@@ -0,0 +1,88 @@
+package com.iflytek.skillhub.auth.federation.adapter;
+
+import com.iflytek.skillhub.auth.connection.core.AdapterCapability;
+import com.iflytek.skillhub.auth.connection.core.AdapterContractVersion;
+import com.iflytek.skillhub.auth.connection.core.AdapterDescriptor;
+import com.iflytek.skillhub.auth.connection.core.AdapterKey;
+import com.iflytek.skillhub.auth.connection.core.AdapterRegistryException;
+import com.iflytek.skillhub.auth.connection.core.AdapterRegistryFailureReason;
+import com.iflytek.skillhub.auth.connection.core.ConnectionKind;
+import com.iflytek.skillhub.auth.connection.core.InteractionModel;
+import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeConfig;
+import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot;
+import com.iflytek.skillhub.auth.federation.core.IdentityAssertion;
+import com.iflytek.skillhub.auth.federation.core.RedirectAuthenticationAdapter;
+import com.iflytek.skillhub.auth.federation.core.RedirectCompleteRequest;
+import com.iflytek.skillhub.auth.federation.core.RedirectStartRequest;
+import com.iflytek.skillhub.auth.federation.core.RedirectStartResult;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class BuiltInRedirectAuthenticationAdapterRegistryTest {
+
+ @Test
+ void require_returnsCompileTimeRegisteredAdapterByStableKeyAndMajorVersion() {
+ TestRedirectAdapter adapter = new TestRedirectAdapter(descriptor(InteractionModel.REDIRECT));
+ BuiltInRedirectAuthenticationAdapterRegistry registry =
+ new BuiltInRedirectAuthenticationAdapterRegistry(List.of(adapter));
+
+ assertThat(registry.require(new AdapterKey("test-redirect"), new AdapterContractVersion(1, 0), 1))
+ .isSameAs(adapter);
+ }
+
+ @Test
+ void constructor_rejectsDescriptorWhoseInteractionDoesNotMatchRedirectInterface() {
+ TestRedirectAdapter adapter = new TestRedirectAdapter(descriptor(InteractionModel.CREDENTIAL));
+
+ assertThatThrownBy(() -> new BuiltInRedirectAuthenticationAdapterRegistry(List.of(adapter)))
+ .isInstanceOfSatisfying(
+ AdapterRegistryException.class,
+ failure -> assertThat(failure.reason())
+ .isEqualTo(AdapterRegistryFailureReason.IMPLEMENTATION_MISMATCH)
+ );
+ }
+
+ private static AdapterDescriptor descriptor(InteractionModel interactionModel) {
+ return new AdapterDescriptor(
+ new AdapterKey("test-redirect"),
+ new AdapterContractVersion(1, 0),
+ ConnectionKind.LOGIN,
+ 1,
+ Optional.of(interactionModel),
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
+ }
+
+ private record TestConfig() implements LoginConnectionRuntimeConfig {
+ }
+
+ private record TestRedirectAdapter(AdapterDescriptor descriptor)
+ implements RedirectAuthenticationAdapter {
+
+ @Override
+ public Class configType() {
+ return TestConfig.class;
+ }
+
+ @Override
+ public RedirectStartResult start(
+ LoginConnectionRuntimeSnapshot connection,
+ RedirectStartRequest request
+ ) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public IdentityAssertion complete(
+ LoginConnectionRuntimeSnapshot connection,
+ RedirectCompleteRequest request
+ ) {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapterContractTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapterContractTest.java
index e6c449ab..ef2fc814 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapterContractTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/RedirectAuthenticationAdapterContractTest.java
@@ -4,7 +4,11 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.auth.connection.core.AdapterKey;
+import com.iflytek.skillhub.auth.connection.core.AdapterCapability;
+import com.iflytek.skillhub.auth.connection.core.AdapterContractVersion;
+import com.iflytek.skillhub.auth.connection.core.AdapterDescriptor;
import com.iflytek.skillhub.auth.connection.core.ConnectionHandle;
+import com.iflytek.skillhub.auth.connection.core.ConnectionKind;
import com.iflytek.skillhub.auth.connection.core.ConnectionUnavailableException;
import com.iflytek.skillhub.auth.connection.core.EnterpriseConnectionRegistry;
import com.iflytek.skillhub.auth.connection.core.InteractionModel;
@@ -89,7 +93,7 @@ class RedirectAuthenticationAdapterContractTest {
);
assertThat(RedirectAuthenticationAdapter.class.getDeclaredMethods())
.extracting(Method::getName)
- .containsExactlyInAnyOrder("adapterKey", "configType", "start", "complete");
+ .containsExactlyInAnyOrder("descriptor", "configType", "start", "complete");
}
@Test
@@ -118,8 +122,15 @@ class RedirectAuthenticationAdapterContractTest {
private static final class TestRedirectAdapter implements RedirectAuthenticationAdapter {
@Override
- public AdapterKey adapterKey() {
- return new AdapterKey("test-redirect");
+ public AdapterDescriptor descriptor() {
+ return new AdapterDescriptor(
+ new AdapterKey("test-redirect"),
+ new AdapterContractVersion(1, 0),
+ ConnectionKind.LOGIN,
+ 1,
+ Optional.of(InteractionModel.REDIRECT),
+ Set.of(AdapterCapability.IDENTITY_ASSERTION)
+ );
}
@Override