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> 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