feat(auth): define enterprise identity contracts

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-07 21:46:47 +08:00
parent 3de0b94a9a
commit 4f06e69224
14 changed files with 383 additions and 0 deletions

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;
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,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,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<C extends LoginConnectionRuntimeConfig> {
AdapterKey adapterKey();
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,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<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("adapterKey", "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 AdapterKey adapterKey() {
return new AdapterKey("test-redirect");
}
@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()
);
}
}
}