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] 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()
+ );
+ }
+ }
+}