mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-05 02:41:49 +00:00
feat(auth): define enterprise identity contracts
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
3de0b94a9a
commit
4f06e69224
14 changed files with 383 additions and 0 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.auth.connection.core;
|
||||
|
||||
/** Marker for immutable, typed and already validated adapter runtime configuration. */
|
||||
public interface LoginConnectionRuntimeConfig {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/** Runtime connection contracts shared by enterprise authentication orchestration and adapters. */
|
||||
package com.iflytek.skillhub.auth.connection.core;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue