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