Merge pull request #896 from iflytek/feat/identity-provider-adapter-contracts

feat(auth): add identity provider adapter contracts
This commit is contained in:
XiaoSeS 2026-09-22 16:13:20 +08:00 • committed by GitHub
commit cc6e998ea1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1246 additions and 0 deletions

View file

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

View file

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

View file

@ -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");
}
}
}

View file

@ -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> interactionModel,
Set<AdapterCapability> 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"));
}
}

View file

@ -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<Integer> SUPPORTED_CONTRACT_MAJORS = Set.of(1);
private static final Set<AdapterCapability> LOGIN_CAPABILITIES = Set.of(
AdapterCapability.IDENTITY_ASSERTION,
AdapterCapability.VERIFIED_EMAIL_ASSERTION,
AdapterCapability.PROFILE_ATTRIBUTE_ASSERTION
);
private static final Set<AdapterCapability> DIRECTORY_CAPABILITIES = Set.of(
AdapterCapability.DIRECTORY_USERS,
AdapterCapability.DIRECTORY_GROUPS,
AdapterCapability.INCREMENTAL_RECONCILIATION,
AdapterCapability.DEPROVISIONING
);
private final Map<RegistrationKey, AdapterDescriptor> descriptorsByKey;
private final List<AdapterDescriptor> descriptors;
public AdapterDescriptorRegistry(Collection<AdapterDescriptor> descriptors) {
Objects.requireNonNull(descriptors, "descriptors");
List<AdapterDescriptor> 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<RegistrationKey, AdapterDescriptor> 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<AdapterDescriptor> 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()
);
}
}
}

View file

@ -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");
}
}
}

View file

@ -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;
}
}

View file

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

View file

@ -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");
}
}
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.auth.connection.core;
/** Independent connection planes understood by the platform registry. */
public enum ConnectionKind {
LOGIN,
DIRECTORY
}

View file

@ -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");
}
}

View file

@ -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);
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.auth.connection.core;
/**
* Browser interaction family implemented by a login adapter.
*
* <p>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.</p>
*/
public enum InteractionModel {
REDIRECT,
CREDENTIAL,
PASSIVE_ASSERTION
}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.auth.connection.core;
/** Marker for immutable, typed and already validated adapter runtime configuration. */
public interface LoginConnectionRuntimeConfig {
}

View file

@ -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<C extends LoginConnectionRuntimeConfig>(
Optional<String> 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<String> normalizeOptionalText(Optional<String> 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;
}
}

View file

@ -0,0 +1,2 @@
/** Runtime connection contracts shared by enterprise authentication orchestration and adapters. */
package com.iflytek.skillhub.auth.connection.core;

View file

@ -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<RegistrationKey, RedirectAuthenticationAdapter<?>> adapters;
public BuiltInRedirectAuthenticationAdapterRegistry(
Collection<? extends RedirectAuthenticationAdapter<?>> adapters
) {
Objects.requireNonNull(adapters, "adapters");
this.descriptors = new AdapterDescriptorRegistry(
adapters.stream().map(RedirectAuthenticationAdapter::descriptor).toList()
);
Map<RegistrationKey, RedirectAuthenticationAdapter<?>> 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()
);
}
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.auth.federation.core;
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<C extends LoginConnectionRuntimeConfig> {
AdapterDescriptor descriptor();
Class<C> configType();
RedirectStartResult start(LoginConnectionRuntimeSnapshot<C> connection, RedirectStartRequest request);
IdentityAssertion complete(LoginConnectionRuntimeSnapshot<C> connection, RedirectCompleteRequest request);
}

View file

@ -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");
}
}

View file

@ -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;
}
}

View file

@ -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<String> 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;
}
}

View file

@ -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");
}
}

View file

@ -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<AdapterCapability> 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)
);
}
}

View file

@ -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<TestConfig> {
@Override
public Class<TestConfig> configType() {
return TestConfig.class;
}
@Override
public RedirectStartResult start(
LoginConnectionRuntimeSnapshot<TestConfig> connection,
RedirectStartRequest request
) {
throw new UnsupportedOperationException();
}
@Override
public IdentityAssertion complete(
LoginConnectionRuntimeSnapshot<TestConfig> connection,
RedirectCompleteRequest request
) {
throw new UnsupportedOperationException();
}
}
}

View file

@ -0,0 +1,171 @@
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.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;
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<TestRuntimeConfig> 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<TestRuntimeConfig> 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("descriptor", "configType", "start", "complete");
}
@Test
void adapterContractExposesNoPlatformStateOrRepositoryTypes() {
Set<String> forbiddenTypeFragments = Set.of(
"UserAccount",
"OrganizationMembership",
"NamespaceMember",
"PlatformPrincipal",
"Repository"
);
Stream<Class<?>> 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<TestRuntimeConfig> {
@Override
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
public Class<TestRuntimeConfig> configType() {
return TestRuntimeConfig.class;
}
@Override
public RedirectStartResult start(
LoginConnectionRuntimeSnapshot<TestRuntimeConfig> connection,
RedirectStartRequest request
) {
return new RedirectStartResult(URI.create(
connection.config().issuer() + "/authorize?transaction=" + request.browserTransactionId()
));
}
@Override
public IdentityAssertion complete(
LoginConnectionRuntimeSnapshot<TestRuntimeConfig> 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()
);
}
}
}