availableProviders
+) {
+ public IdentityLinkAccountState {
+ linkedProviders = List.copyOf(linkedProviders);
+ availableProviders = List.copyOf(availableProviders);
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkActor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkActor.java
new file mode 100644
index 00000000..6eb3d140
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkActor.java
@@ -0,0 +1,73 @@
+package com.iflytek.skillhub.auth.identity;
+
+import java.util.Objects;
+
+/**
+ * Server-owned account and session proof used by Identity Link workflows.
+ *
+ * The raw session nonce is intentionally omitted from {@link #toString()}.
+ */
+public final class IdentityLinkActor {
+
+ private final String userId;
+ private final String authenticationProvider;
+ private final String sessionNonce;
+ private final IdentityLoginContext auditContext;
+
+ public IdentityLinkActor(
+ String userId,
+ String authenticationProvider,
+ String sessionNonce,
+ IdentityLoginContext auditContext) {
+ this.userId = requireText(userId, "userId", 128);
+ this.authenticationProvider = requireText(
+ authenticationProvider,
+ "authenticationProvider",
+ 64);
+ this.sessionNonce = requireText(
+ sessionNonce,
+ "sessionNonce",
+ 256);
+ this.auditContext = Objects.requireNonNull(
+ auditContext,
+ "auditContext");
+ }
+
+ public String userId() {
+ return userId;
+ }
+
+ String authenticationProvider() {
+ return authenticationProvider;
+ }
+
+ String sessionNonce() {
+ return sessionNonce;
+ }
+
+ public IdentityLoginContext auditContext() {
+ return auditContext;
+ }
+
+ @Override
+ public String toString() {
+ return "IdentityLinkActor[userId="
+ + userId
+ + ", authenticationProvider="
+ + authenticationProvider
+ + "]";
+ }
+
+ private static String requireText(
+ String value,
+ String fieldName,
+ int maximumLength) {
+ if (value == null
+ || value.isBlank()
+ || value.length() > maximumLength) {
+ throw new IllegalArgumentException(
+ "Invalid identity link actor " + fieldName);
+ }
+ return value;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBindingView.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBindingView.java
new file mode 100644
index 00000000..776e6eaa
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBindingView.java
@@ -0,0 +1,29 @@
+package com.iflytek.skillhub.auth.identity;
+
+import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
+import java.util.Set;
+
+public record IdentityLinkBindingView(
+ long bindingId,
+ String providerCode,
+ String displayName,
+ Set methodTypes,
+ boolean usable,
+ boolean canUnlink
+) {
+ public IdentityLinkBindingView {
+ if (bindingId <= 0) {
+ throw new IllegalArgumentException(
+ "Identity binding id must be positive");
+ }
+ if (providerCode == null || providerCode.isBlank()) {
+ throw new IllegalArgumentException(
+ "Identity provider code is required");
+ }
+ if (displayName == null || displayName.isBlank()) {
+ throw new IllegalArgumentException(
+ "Identity provider display name is required");
+ }
+ methodTypes = Set.copyOf(methodTypes);
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserFlow.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserFlow.java
new file mode 100644
index 00000000..50697112
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserFlow.java
@@ -0,0 +1,10 @@
+package com.iflytek.skillhub.auth.identity;
+
+import java.util.UUID;
+
+public record IdentityLinkBrowserFlow(
+ UUID intentId,
+ IdentityLinkBrowserPhase phase,
+ IdentityLinkActor actor
+) {
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserPhase.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserPhase.java
new file mode 100644
index 00000000..852da8b3
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserPhase.java
@@ -0,0 +1,6 @@
+package com.iflytek.skillhub.auth.identity;
+
+public enum IdentityLinkBrowserPhase {
+ REAUTHENTICATE,
+ LINK
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkException.java
new file mode 100644
index 00000000..00b3affb
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkException.java
@@ -0,0 +1,26 @@
+package com.iflytek.skillhub.auth.identity;
+
+import com.iflytek.skillhub.auth.exception.AuthFlowException;
+
+public final class IdentityLinkException extends AuthFlowException {
+
+ private final IdentityLinkFailureCode reasonCode;
+
+ public IdentityLinkException(IdentityLinkFailureCode reasonCode) {
+ this(reasonCode, null);
+ }
+
+ public IdentityLinkException(
+ IdentityLinkFailureCode reasonCode,
+ Throwable cause) {
+ super(reasonCode.status(), reasonCode.messageCode());
+ this.reasonCode = reasonCode;
+ if (cause != null) {
+ initCause(cause);
+ }
+ }
+
+ public IdentityLinkFailureCode getReasonCode() {
+ return reasonCode;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkFailureCode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkFailureCode.java
new file mode 100644
index 00000000..15e26323
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkFailureCode.java
@@ -0,0 +1,63 @@
+package com.iflytek.skillhub.auth.identity;
+
+import org.springframework.http.HttpStatus;
+
+public enum IdentityLinkFailureCode {
+ INTENT_NOT_FOUND(
+ HttpStatus.NOT_FOUND,
+ "error.auth.identityLink.intentNotFound"),
+ REAUTHENTICATION_REQUIRED(
+ HttpStatus.UNAUTHORIZED,
+ "error.auth.identityLink.reauthenticationRequired"),
+ SESSION_MISMATCH(
+ HttpStatus.FORBIDDEN,
+ "error.auth.identityLink.sessionMismatch"),
+ INTENT_EXPIRED(
+ HttpStatus.GONE,
+ "error.auth.identityLink.intentExpired"),
+ ALREADY_CONSUMED(
+ HttpStatus.CONFLICT,
+ "error.auth.identityLink.alreadyConsumed"),
+ ACTIVE_INTENT_EXISTS(
+ HttpStatus.CONFLICT,
+ "error.auth.identityLink.activeIntentExists"),
+ ACCOUNT_NOT_ELIGIBLE(
+ HttpStatus.CONFLICT,
+ "error.auth.identityLink.accountNotEligible"),
+ PROVIDER_UNAVAILABLE(
+ HttpStatus.SERVICE_UNAVAILABLE,
+ "error.auth.identityLink.providerUnavailable"),
+ PROVIDER_AUTHENTICATION_FAILED(
+ HttpStatus.UNAUTHORIZED,
+ "error.auth.identityLink.providerAuthenticationFailed"),
+ ALREADY_LINKED(
+ HttpStatus.CONFLICT,
+ "error.auth.identityLink.alreadyLinked"),
+ IDENTITY_IN_USE(
+ HttpStatus.CONFLICT,
+ "error.auth.identityLink.identityInUse"),
+ FINAL_LOGIN_METHOD(
+ HttpStatus.CONFLICT,
+ "error.auth.identityLink.finalLoginMethod"),
+ INVALID_OPERATION(
+ HttpStatus.BAD_REQUEST,
+ "error.auth.identityLink.invalidOperation");
+
+ private final HttpStatus status;
+ private final String messageCode;
+
+ IdentityLinkFailureCode(
+ HttpStatus status,
+ String messageCode) {
+ this.status = status;
+ this.messageCode = messageCode;
+ }
+
+ public HttpStatus status() {
+ return status;
+ }
+
+ public String messageCode() {
+ return messageCode;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntent.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntent.java
new file mode 100644
index 00000000..f4aee583
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntent.java
@@ -0,0 +1,24 @@
+package com.iflytek.skillhub.auth.identity;
+
+import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
+import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.UUID;
+
+public record IdentityLinkIntent(
+ UUID id,
+ IdentityLinkOperation operation,
+ IdentityLinkRequestStatus status,
+ String providerCode,
+ Long targetBindingId,
+ Instant expiresAt
+) {
+ public IdentityLinkIntent {
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(operation, "operation");
+ Objects.requireNonNull(status, "status");
+ Objects.requireNonNull(providerCode, "providerCode");
+ Objects.requireNonNull(expiresAt, "expiresAt");
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentService.java
new file mode 100644
index 00000000..9a3acffe
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentService.java
@@ -0,0 +1,152 @@
+package com.iflytek.skillhub.auth.identity;
+
+import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
+import com.iflytek.skillhub.auth.local.LocalAuthService;
+import java.sql.SQLException;
+import java.util.Objects;
+import java.util.UUID;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+
+/**
+ * Public workflow facade for creating, inspecting, reauthenticating, and
+ * consuming Identity Link intents.
+ *
+ * Raw session nonces are supplied by the HTTP session boundary and are
+ * never persisted by this service.
+ */
+@Service
+public class IdentityLinkIntentService {
+
+ private final IdentityLinkTransaction transaction;
+ private final LocalAuthService localAuthService;
+
+ IdentityLinkIntentService(
+ IdentityLinkTransaction transaction,
+ LocalAuthService localAuthService) {
+ this.transaction = transaction;
+ this.localAuthService = localAuthService;
+ }
+
+ public IdentityLinkIntent createLinkIntent(
+ IdentityLinkActor actor,
+ UUID intentId,
+ String providerCode) {
+ Objects.requireNonNull(actor, "actor");
+ Objects.requireNonNull(intentId, "intentId");
+ try {
+ return transaction.createLinkIntent(
+ actor,
+ intentId,
+ providerCode);
+ } catch (DataIntegrityViolationException exception) {
+ if (isUniqueConstraintViolation(exception)) {
+ throw new IdentityLinkException(
+ IdentityLinkFailureCode.ACTIVE_INTENT_EXISTS,
+ exception);
+ }
+ throw exception;
+ }
+ }
+
+ public IdentityLinkIntent createUnlinkIntent(
+ IdentityLinkActor actor,
+ UUID intentId,
+ long bindingId) {
+ Objects.requireNonNull(actor, "actor");
+ Objects.requireNonNull(intentId, "intentId");
+ try {
+ return transaction.createUnlinkIntent(
+ actor,
+ intentId,
+ bindingId);
+ } catch (DataIntegrityViolationException exception) {
+ if (isUniqueConstraintViolation(exception)) {
+ throw new IdentityLinkException(
+ IdentityLinkFailureCode.ACTIVE_INTENT_EXISTS,
+ exception);
+ }
+ throw exception;
+ }
+ }
+
+ public IdentityLinkIntent getIntent(
+ IdentityLinkActor actor,
+ UUID intentId) {
+ return transaction.getIntent(actor, intentId);
+ }
+
+ public IdentityLinkIntent cancel(
+ IdentityLinkActor actor,
+ UUID intentId) {
+ return transaction.cancel(actor, intentId);
+ }
+
+ public IdentityLinkIntent reauthenticateLocal(
+ IdentityLinkActor actor,
+ UUID intentId,
+ String password) {
+ IdentityLinkIntent intent = transaction.getIntent(
+ actor,
+ intentId);
+ if (intent.status()
+ != IdentityLinkRequestStatus.PENDING_REAUTHENTICATION) {
+ throw new IdentityLinkException(
+ IdentityLinkFailureCode.ALREADY_CONSUMED);
+ }
+ localAuthService.reauthenticate(
+ actor.userId(),
+ password);
+ return transaction.markLocalReauthenticated(
+ actor,
+ intentId);
+ }
+
+ public IdentityLinkIntent prepareExternalReauthentication(
+ IdentityLinkActor actor,
+ UUID intentId,
+ String providerCode,
+ IdentityProviderLoginMethodType methodType) {
+ return transaction.prepareExternalReauthentication(
+ actor,
+ intentId,
+ providerCode,
+ methodType);
+ }
+
+ public IdentityLinkIntent prepareExternalLink(
+ IdentityLinkActor actor,
+ UUID intentId,
+ IdentityProviderLoginMethodType methodType) {
+ return transaction.prepareExternalLink(
+ actor,
+ intentId,
+ methodType);
+ }
+
+ public IdentityLinkIntent completeUnlink(
+ IdentityLinkActor actor,
+ UUID intentId) {
+ return transaction.completeUnlink(actor, intentId);
+ }
+
+ public IdentityLinkAccountState accountState(String userId) {
+ return transaction.accountState(userId);
+ }
+
+ private boolean isUniqueConstraintViolation(Throwable failure) {
+ Throwable current = failure;
+ while (current != null) {
+ if (current instanceof SQLException sqlException
+ && "23505".equals(sqlException.getSQLState())) {
+ return true;
+ }
+ Throwable cause = current.getCause();
+ if (cause == current) {
+ break;
+ }
+ current = cause;
+ }
+ return false;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkOutcome.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkOutcome.java
new file mode 100644
index 00000000..b58311c8
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkOutcome.java
@@ -0,0 +1,28 @@
+package com.iflytek.skillhub.auth.identity;
+
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import java.util.Objects;
+
+public sealed interface IdentityLinkOutcome {
+
+ record Reauthenticated(
+ PlatformPrincipal principal
+ ) implements IdentityLinkOutcome {
+ public Reauthenticated {
+ Objects.requireNonNull(principal, "principal");
+ }
+ }
+
+ record Linked(
+ PlatformPrincipal principal,
+ long bindingId
+ ) implements IdentityLinkOutcome {
+ public Linked {
+ Objects.requireNonNull(principal, "principal");
+ if (bindingId <= 0) {
+ throw new IllegalArgumentException(
+ "Identity binding id must be positive");
+ }
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkProviderView.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkProviderView.java
new file mode 100644
index 00000000..aeaf7a95
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkProviderView.java
@@ -0,0 +1,21 @@
+package com.iflytek.skillhub.auth.identity;
+
+import java.util.Set;
+
+public record IdentityLinkProviderView(
+ String providerCode,
+ String displayName,
+ Set methodTypes
+) {
+ public IdentityLinkProviderView {
+ if (providerCode == null || providerCode.isBlank()) {
+ throw new IllegalArgumentException(
+ "Identity provider code is required");
+ }
+ if (displayName == null || displayName.isBlank()) {
+ throw new IllegalArgumentException(
+ "Identity provider display name is required");
+ }
+ methodTypes = Set.copyOf(methodTypes);
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java
new file mode 100644
index 00000000..d276449a
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java
@@ -0,0 +1,286 @@
+package com.iflytek.skillhub.auth.identity;
+
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
+import java.io.Serial;
+import java.io.Serializable;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * Owns the raw, high-entropy session state used by Identity Link workflows.
+ *
+ * Only a SHA-256 digest is stored outside the session. Raw nonces and OAuth
+ * state values are never returned by API DTOs or written to the database.
+ */
+@Component
+public class IdentityLinkSessionManager {
+
+ private static final String NONCE_ATTRIBUTE_PREFIX =
+ "skillhub.identityLink.nonce.";
+ private static final String PENDING_BROWSER_FLOW_ATTRIBUTE =
+ "skillhub.identityLink.browser.pending";
+ private static final String ACTIVE_BROWSER_FLOW_ATTRIBUTE =
+ "skillhub.identityLink.browser.active";
+ private static final Duration BROWSER_FLOW_TTL =
+ Duration.ofMinutes(5);
+
+ private final SecureRandom secureRandom;
+ private final IdentityLinkStateHasher stateHasher;
+ private final Clock clock;
+
+ @Autowired
+ public IdentityLinkSessionManager(
+ IdentityLinkStateHasher stateHasher,
+ Clock clock) {
+ this(
+ new SecureRandom(),
+ stateHasher,
+ clock);
+ }
+
+ IdentityLinkSessionManager(
+ SecureRandom secureRandom,
+ IdentityLinkStateHasher stateHasher,
+ Clock clock) {
+ this.secureRandom = secureRandom;
+ this.stateHasher = stateHasher;
+ this.clock = clock;
+ }
+
+ public IdentityLinkActor start(
+ HttpSession session,
+ UUID intentId,
+ IdentityLoginContext context) {
+ Objects.requireNonNull(session, "session");
+ Objects.requireNonNull(intentId, "intentId");
+ byte[] nonceBytes = new byte[32];
+ secureRandom.nextBytes(nonceBytes);
+ String nonce = Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(nonceBytes);
+ session.setAttribute(
+ nonceAttribute(intentId),
+ nonce);
+ return actor(session, intentId, context);
+ }
+
+ public IdentityLinkActor actor(
+ HttpSession session,
+ UUID intentId,
+ IdentityLoginContext context) {
+ Objects.requireNonNull(session, "session");
+ Objects.requireNonNull(intentId, "intentId");
+ Object principalValue =
+ session.getAttribute("platformPrincipal");
+ if (!(principalValue instanceof PlatformPrincipal principal)) {
+ throw new IdentityLinkException(
+ IdentityLinkFailureCode.SESSION_MISMATCH);
+ }
+ Object nonceValue = session.getAttribute(
+ nonceAttribute(intentId));
+ if (!(nonceValue instanceof String nonce)
+ || nonce.isBlank()) {
+ throw new IdentityLinkException(
+ IdentityLinkFailureCode.SESSION_MISMATCH);
+ }
+ String authenticationProvider =
+ principal.oauthProvider() == null
+ || principal.oauthProvider().isBlank()
+ ? "session"
+ : principal.oauthProvider();
+ return new IdentityLinkActor(
+ principal.userId(),
+ authenticationProvider,
+ nonce,
+ context);
+ }
+
+ public void remove(HttpSession session, UUID intentId) {
+ if (session == null || intentId == null) {
+ return;
+ }
+ session.removeAttribute(nonceAttribute(intentId));
+ clearBrowserFlowForIntent(session, intentId);
+ }
+
+ public void prepareBrowserFlow(
+ HttpSession session,
+ UUID intentId,
+ IdentityLinkBrowserPhase phase,
+ String providerCode,
+ IdentityLoginContext context) {
+ actor(session, intentId, context);
+ PendingBrowserFlow pending = new PendingBrowserFlow(
+ intentId,
+ Objects.requireNonNull(phase, "phase"),
+ requireProviderCode(providerCode),
+ now().plus(BROWSER_FLOW_TTL));
+ session.setAttribute(
+ PENDING_BROWSER_FLOW_ATTRIBUTE,
+ pending);
+ session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
+ }
+
+ /**
+ * Binds a prepared link flow to the OAuth authorization request generated
+ * by Spring Security. The raw OAuth state remains in Spring Security's
+ * authorization request repository; only its digest is copied here.
+ */
+ public void activateBrowserFlow(
+ HttpSession session,
+ String providerCode,
+ String oauthState) {
+ if (session == null) {
+ return;
+ }
+ Object value = session.getAttribute(
+ PENDING_BROWSER_FLOW_ATTRIBUTE);
+ session.removeAttribute(PENDING_BROWSER_FLOW_ATTRIBUTE);
+ if (!(value instanceof PendingBrowserFlow pending)
+ || pending.expiresAt().isBefore(now())
+ || !pending.providerCode().equals(providerCode)
+ || oauthState == null
+ || oauthState.isBlank()) {
+ return;
+ }
+ session.setAttribute(
+ ACTIVE_BROWSER_FLOW_ATTRIBUTE,
+ new ActiveBrowserFlow(
+ pending.intentId(),
+ pending.phase(),
+ pending.providerCode(),
+ stateHasher.hash(oauthState),
+ pending.expiresAt()));
+ }
+
+ public Optional consumeBrowserFlow(
+ HttpServletRequest request,
+ String providerCode,
+ IdentityLoginContext context) {
+ HttpSession session = request.getSession(false);
+ if (session == null) {
+ return Optional.empty();
+ }
+ Object value = session.getAttribute(
+ ACTIVE_BROWSER_FLOW_ATTRIBUTE);
+ if (!(value instanceof ActiveBrowserFlow active)) {
+ return Optional.empty();
+ }
+ session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
+ String callbackState = request.getParameter("state");
+ if (active.expiresAt().isBefore(now())
+ || !active.providerCode().equals(providerCode)
+ || !stateHasher.matches(
+ callbackState,
+ active.oauthStateHash())) {
+ throw new IdentityLinkException(
+ IdentityLinkFailureCode.SESSION_MISMATCH);
+ }
+ return Optional.of(new IdentityLinkBrowserFlow(
+ active.intentId(),
+ active.phase(),
+ actor(
+ session,
+ active.intentId(),
+ context)));
+ }
+
+ public void clearBrowserFlow(HttpSession session) {
+ if (session == null) {
+ return;
+ }
+ session.removeAttribute(PENDING_BROWSER_FLOW_ATTRIBUTE);
+ session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
+ }
+
+ /**
+ * Clears a failed browser flow while retaining the session-bound intent
+ * nonce so the account-security UI can safely resume or cancel it.
+ */
+ public Optional consumeFailedBrowserFlow(
+ HttpSession session) {
+ if (session == null) {
+ return Optional.empty();
+ }
+ Object active = session.getAttribute(
+ ACTIVE_BROWSER_FLOW_ATTRIBUTE);
+ Object pending = session.getAttribute(
+ PENDING_BROWSER_FLOW_ATTRIBUTE);
+ clearBrowserFlow(session);
+ if (active instanceof ActiveBrowserFlow flow) {
+ return Optional.of(flow.intentId());
+ }
+ if (pending instanceof PendingBrowserFlow flow) {
+ return Optional.of(flow.intentId());
+ }
+ return Optional.empty();
+ }
+
+ private void clearBrowserFlowForIntent(
+ HttpSession session,
+ UUID intentId) {
+ Object pending = session.getAttribute(
+ PENDING_BROWSER_FLOW_ATTRIBUTE);
+ if (pending instanceof PendingBrowserFlow flow
+ && flow.intentId().equals(intentId)) {
+ session.removeAttribute(
+ PENDING_BROWSER_FLOW_ATTRIBUTE);
+ }
+ Object active = session.getAttribute(
+ ACTIVE_BROWSER_FLOW_ATTRIBUTE);
+ if (active instanceof ActiveBrowserFlow flow
+ && flow.intentId().equals(intentId)) {
+ session.removeAttribute(
+ ACTIVE_BROWSER_FLOW_ATTRIBUTE);
+ }
+ }
+
+ private String nonceAttribute(UUID intentId) {
+ return NONCE_ATTRIBUTE_PREFIX + intentId;
+ }
+
+ private String requireProviderCode(String providerCode) {
+ if (providerCode == null
+ || providerCode.isBlank()
+ || providerCode.length() > 64) {
+ throw new IdentityLinkException(
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
+ }
+ return providerCode;
+ }
+
+ private Instant now() {
+ return Instant.now(clock);
+ }
+
+ private record PendingBrowserFlow(
+ UUID intentId,
+ IdentityLinkBrowserPhase phase,
+ String providerCode,
+ Instant expiresAt
+ ) implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ }
+
+ private record ActiveBrowserFlow(
+ UUID intentId,
+ IdentityLinkBrowserPhase phase,
+ String providerCode,
+ String oauthStateHash,
+ Instant expiresAt
+ ) implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkStateHasher.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkStateHasher.java
new file mode 100644
index 00000000..4e769b52
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkStateHasher.java
@@ -0,0 +1,44 @@
+package com.iflytek.skillhub.auth.identity;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import org.springframework.stereotype.Component;
+
+@Component
+final class IdentityLinkStateHasher {
+
+ String hash(String rawState) {
+ if (rawState == null || rawState.isBlank()) {
+ throw new IllegalArgumentException(
+ "Identity link state must not be blank");
+ }
+ return HexFormat.of().formatHex(
+ sha256(rawState.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ boolean matches(String rawState, String expectedHash) {
+ if (rawState == null || expectedHash == null) {
+ return false;
+ }
+ byte[] actual = sha256(rawState.getBytes(StandardCharsets.UTF_8));
+ byte[] expected;
+ try {
+ expected = HexFormat.of().parseHex(expectedHash);
+ } catch (IllegalArgumentException exception) {
+ return false;
+ }
+ return MessageDigest.isEqual(actual, expected);
+ }
+
+ private byte[] sha256(byte[] value) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(value);
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException(
+ "SHA-256 is unavailable",
+ exception);
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java
new file mode 100644
index 00000000..32e74a23
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java
@@ -0,0 +1,917 @@
+package com.iflytek.skillhub.auth.identity;
+
+import com.iflytek.skillhub.auth.entity.IdentityBinding;
+import com.iflytek.skillhub.auth.entity.IdentityBindingStatus;
+import com.iflytek.skillhub.auth.entity.IdentityBindingSubject;
+import com.iflytek.skillhub.auth.entity.IdentityBindingSubjectStatus;
+import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
+import com.iflytek.skillhub.auth.entity.IdentityLinkRequest;
+import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
+import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
+import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
+import com.iflytek.skillhub.auth.repository.IdentityBindingSubjectRepository;
+import com.iflytek.skillhub.auth.repository.IdentityLinkRequestRepository;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import com.iflytek.skillhub.domain.audit.AuditLogService;
+import com.iflytek.skillhub.domain.user.UserAccount;
+import com.iflytek.skillhub.domain.user.UserAccountRepository;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Short PostgreSQL transaction for Identity Link request state, Binding V2
+ * creation, and safe revocation. Protocol I/O and credential verification run
+ * before these methods are invoked.
+ */
+@Service
+class IdentityLinkTransaction {
+
+ static final Duration INTENT_TTL = Duration.ofMinutes(10);
+ private static final String USER_UNLINK_REASON =
+ "User removed linked login method";
+
+ private final IdentityLinkRequestRepository requestRepository;
+ private final IdentityBindingRepository bindingRepository;
+ private final IdentityBindingSubjectRepository subjectRepository;
+ private final LocalCredentialRepository credentialRepository;
+ private final UserAccountRepository userRepository;
+ private final IdentityProviderRegistry providerRegistry;
+ private final IdentityLinkStateHasher stateHasher;
+ private final AccountLoginGuard accountLoginGuard;
+ private final PlatformPrincipalFactory principalFactory;
+ private final AuditLogService auditLogService;
+ private final Clock clock;
+
+ IdentityLinkTransaction(
+ IdentityLinkRequestRepository requestRepository,
+ IdentityBindingRepository bindingRepository,
+ IdentityBindingSubjectRepository subjectRepository,
+ LocalCredentialRepository credentialRepository,
+ UserAccountRepository userRepository,
+ IdentityProviderRegistry providerRegistry,
+ IdentityLinkStateHasher stateHasher,
+ AccountLoginGuard accountLoginGuard,
+ PlatformPrincipalFactory principalFactory,
+ AuditLogService auditLogService,
+ Clock clock) {
+ this.requestRepository = requestRepository;
+ this.bindingRepository = bindingRepository;
+ this.subjectRepository = subjectRepository;
+ this.credentialRepository = credentialRepository;
+ this.userRepository = userRepository;
+ this.providerRegistry = providerRegistry;
+ this.stateHasher = stateHasher;
+ this.accountLoginGuard = accountLoginGuard;
+ this.principalFactory = principalFactory;
+ this.auditLogService = auditLogService;
+ this.clock = clock;
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public IdentityLinkIntent createLinkIntent(
+ IdentityLinkActor actor,
+ UUID intentId,
+ String providerCode) {
+ Instant now = now();
+ requireEligibleAccount(actor.userId());
+ requireReadyLinkProvider(providerCode);
+ boolean alreadyLinked = bindingRepository
+ .findByUserIdAndStatus(
+ actor.userId(),
+ IdentityBindingStatus.ACTIVE)
+ .stream()
+ .anyMatch(binding -> binding.getProviderCode()
+ .equals(providerCode));
+ if (alreadyLinked) {
+ throw failure(IdentityLinkFailureCode.ALREADY_LINKED);
+ }
+ requireNoActiveRequest(actor, now);
+
+ IdentityLinkRequest request = new IdentityLinkRequest(
+ intentId,
+ actor.userId(),
+ IdentityLinkOperation.LINK,
+ providerCode,
+ null,
+ stateHasher.hash(actor.sessionNonce()),
+ now.plus(INTENT_TTL),
+ now);
+ requestRepository.saveAndFlush(request);
+ recordAudit(
+ actor,
+ "IDENTITY_LINK_INTENT_CREATED",
+ request,
+ "pending_reauthentication");
+ return toIntent(request);
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public IdentityLinkIntent createUnlinkIntent(
+ IdentityLinkActor actor,
+ UUID intentId,
+ long bindingId) {
+ Instant now = now();
+ requireEligibleAccount(actor.userId());
+ requireNoActiveRequest(actor, now);
+ IdentityBinding binding = bindingRepository
+ .findByIdAndStatusForUpdate(
+ bindingId,
+ IdentityBindingStatus.ACTIVE)
+ .filter(candidate -> candidate.getUserId()
+ .equals(actor.userId()))
+ .orElseThrow(() ->
+ failure(IdentityLinkFailureCode.INTENT_NOT_FOUND));
+
+ IdentityLinkRequest request = new IdentityLinkRequest(
+ intentId,
+ actor.userId(),
+ IdentityLinkOperation.UNLINK,
+ binding.getProviderCode(),
+ binding.getId(),
+ stateHasher.hash(actor.sessionNonce()),
+ now.plus(INTENT_TTL),
+ now);
+ requestRepository.saveAndFlush(request);
+ recordAudit(
+ actor,
+ "IDENTITY_UNLINK_INTENT_CREATED",
+ request,
+ "pending_reauthentication");
+ return toIntent(request);
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public IdentityLinkIntent getIntent(
+ IdentityLinkActor actor,
+ UUID intentId) {
+ IdentityLinkRequest request = requireRequest(
+ actor,
+ intentId);
+ requireActive(request, actor);
+ return toIntent(request);
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public IdentityLinkIntent cancel(
+ IdentityLinkActor actor,
+ UUID intentId) {
+ IdentityLinkRequest request = requireRequest(
+ actor,
+ intentId);
+ requireActive(request, actor);
+ request.cancel(now());
+ recordAudit(
+ actor,
+ "IDENTITY_LINK_INTENT_CANCELLED",
+ request,
+ "cancelled");
+ return toIntent(request);
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public IdentityLinkIntent markLocalReauthenticated(
+ IdentityLinkActor actor,
+ UUID intentId) {
+ IdentityLinkRequest request = requireRequest(
+ actor,
+ intentId);
+ requirePendingReauthentication(request, actor);
+ requireEligibleAccount(actor.userId());
+ request.markReauthenticated("local-password", now());
+ recordAudit(
+ actor,
+ "IDENTITY_LINK_ACCOUNT_REAUTHENTICATED",
+ request,
+ "local-password");
+ return toIntent(request);
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public IdentityLinkIntent prepareExternalReauthentication(
+ IdentityLinkActor actor,
+ UUID intentId,
+ String providerCode,
+ IdentityProviderLoginMethodType methodType) {
+ IdentityLinkRequest request = requireRequest(
+ actor,
+ intentId);
+ requirePendingReauthentication(request, actor);
+ requireEligibleAccount(actor.userId());
+ requireProviderCapability(
+ actor,
+ request,
+ providerCode,
+ methodType);
+ boolean linked = bindingRepository
+ .findByUserIdAndStatus(
+ actor.userId(),
+ IdentityBindingStatus.ACTIVE)
+ .stream()
+ .anyMatch(binding -> binding.getProviderCode()
+ .equals(providerCode));
+ if (!linked) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
+ }
+ return toIntent(request);
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public IdentityLinkIntent prepareExternalLink(
+ IdentityLinkActor actor,
+ UUID intentId,
+ IdentityProviderLoginMethodType methodType) {
+ IdentityLinkRequest request = requireRequest(
+ actor,
+ intentId);
+ requireReady(
+ request,
+ actor,
+ IdentityLinkOperation.LINK);
+ requireEligibleAccount(actor.userId());
+ requireProviderCapability(
+ actor,
+ request,
+ request.getProviderCode(),
+ methodType);
+ boolean alreadyLinked = bindingRepository
+ .findByUserIdAndStatus(
+ actor.userId(),
+ IdentityBindingStatus.ACTIVE)
+ .stream()
+ .anyMatch(binding -> binding.getProviderCode()
+ .equals(request.getProviderCode()));
+ if (alreadyLinked) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
+ }
+ return toIntent(request);
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public PlatformPrincipal markExternalReauthenticated(
+ IdentityLinkActor actor,
+ UUID intentId,
+ IdentityAssertion assertion,
+ ProviderDescriptor descriptor) {
+ IdentityLinkRequest request = requireRequest(
+ actor,
+ intentId);
+ requirePendingReauthentication(request, actor);
+ UserAccount user = requireEligibleAccount(actor.userId());
+ IdentityBinding binding = resolveAuthenticatedBinding(
+ assertion,
+ descriptor);
+ if (!binding.getUserId().equals(actor.userId())) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE);
+ }
+ request.markReauthenticated(
+ "provider:" + assertion.provider().providerCode(),
+ now());
+ recordAudit(
+ actor,
+ "IDENTITY_LINK_ACCOUNT_REAUTHENTICATED",
+ request,
+ assertion.provider().providerCode());
+ return principalFactory.create(
+ user,
+ actor.authenticationProvider());
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public LinkedBinding link(
+ IdentityLinkActor actor,
+ UUID intentId,
+ IdentityAssertion assertion,
+ ProviderDescriptor descriptor) {
+ IdentityLinkRequest request = requireRequest(
+ actor,
+ intentId);
+ requireReady(request, actor, IdentityLinkOperation.LINK);
+ if (!request.getProviderCode().equals(
+ assertion.provider().providerCode())
+ || !request.getProviderCode().equals(
+ descriptor.providerCode())) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.INVALID_OPERATION);
+ }
+ UserAccount user = requireEligibleAccount(actor.userId());
+ boolean alreadyLinked = bindingRepository
+ .findByUserIdAndStatus(
+ actor.userId(),
+ IdentityBindingStatus.ACTIVE)
+ .stream()
+ .anyMatch(binding -> binding.getProviderCode()
+ .equals(descriptor.providerCode()));
+ if (alreadyLinked) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.ALREADY_LINKED);
+ }
+ requireSubjectsUnbound(
+ actor,
+ request,
+ assertion,
+ descriptor);
+
+ ExternalSubject legacySubject = assertion.requireUniqueSubject(
+ descriptor.legacyPrimarySubjectType());
+ IdentityBinding binding = new IdentityBinding(
+ actor.userId(),
+ descriptor.providerCode(),
+ legacySubject.value(),
+ assertion.profile().displayName());
+ binding.recordAuthentication(
+ assertion.evidence().authenticatedAt());
+ IdentityBinding savedBinding =
+ bindingRepository.saveAndFlush(binding);
+ if (savedBinding.getId() == null) {
+ throw new IllegalStateException(
+ "Identity binding id was not assigned");
+ }
+ List subjects =
+ assertion.allSubjects().stream()
+ .map(subject -> new IdentityBindingSubject(
+ savedBinding.getId(),
+ savedBinding.getProviderCode(),
+ subject.type(),
+ subject.value(),
+ subject.equals(
+ assertion.primarySubject()),
+ assertion.evidence()
+ .authenticatedAt()))
+ .toList();
+ subjectRepository.saveAllAndFlush(subjects);
+ request.complete(now());
+ recordAudit(
+ actor,
+ "IDENTITY_BINDING_LINKED",
+ request,
+ descriptor.providerCode());
+ return new LinkedBinding(
+ principalFactory.create(
+ user,
+ actor.authenticationProvider()),
+ savedBinding.getId());
+ }
+
+ @Transactional(noRollbackFor = IdentityLinkException.class)
+ public IdentityLinkIntent completeUnlink(
+ IdentityLinkActor actor,
+ UUID intentId) {
+ IdentityLinkRequest request = requireRequest(
+ actor,
+ intentId);
+ requireReady(
+ request,
+ actor,
+ IdentityLinkOperation.UNLINK);
+ requireEligibleAccount(actor.userId());
+ IdentityBinding binding = bindingRepository
+ .findByIdAndStatusForUpdate(
+ request.getTargetBindingId(),
+ IdentityBindingStatus.ACTIVE)
+ .filter(candidate -> candidate.getUserId()
+ .equals(actor.userId()))
+ .orElseThrow(() ->
+ reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.ALREADY_CONSUMED));
+ if (!hasOtherUsableLoginMethod(
+ actor.userId(),
+ binding.getId())) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.FINAL_LOGIN_METHOD);
+ }
+
+ Instant revokedAt = now();
+ List activeSubjects =
+ subjectRepository.findByBindingIdAndStatusForUpdate(
+ binding.getId(),
+ IdentityBindingSubjectStatus.ACTIVE);
+ if (activeSubjects.isEmpty()) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.ALREADY_CONSUMED);
+ }
+ activeSubjects.forEach(subject ->
+ subject.revoke(revokedAt));
+ subjectRepository.saveAll(activeSubjects);
+ binding.revoke(
+ actor.userId(),
+ USER_UNLINK_REASON,
+ revokedAt);
+ bindingRepository.save(binding);
+ request.complete(revokedAt);
+ recordAudit(
+ actor,
+ "IDENTITY_BINDING_REVOKED",
+ request,
+ binding.getProviderCode());
+ return toIntent(request);
+ }
+
+ @Transactional(readOnly = true)
+ public IdentityLinkAccountState accountState(String userId) {
+ requireEligibleAccountForRead(userId);
+ List bindings = bindingRepository
+ .findByUserIdAndStatus(
+ userId,
+ IdentityBindingStatus.ACTIVE)
+ .stream()
+ .sorted(Comparator.comparing(
+ IdentityBinding::getProviderCode))
+ .toList();
+ Map readyProviders =
+ readyProviders();
+ boolean localPasswordEnabled =
+ credentialRepository.existsByUserId(userId);
+ long usableMethodCount =
+ localPasswordEnabled ? 1 : 0;
+ usableMethodCount += bindings.stream()
+ .filter(binding -> readyProviders.containsKey(
+ binding.getProviderCode()))
+ .count();
+
+ long totalUsableMethodCount = usableMethodCount;
+ List linked =
+ bindings.stream()
+ .map(binding -> {
+ ReadyProvider provider =
+ readyProviders.get(
+ binding.getProviderCode());
+ boolean usable = provider != null;
+ boolean anotherUsableMethod =
+ totalUsableMethodCount
+ - (usable ? 1 : 0)
+ > 0;
+ return new IdentityLinkBindingView(
+ binding.getId(),
+ binding.getProviderCode(),
+ provider == null
+ ? binding.getProviderCode()
+ : provider.displayName(),
+ provider == null
+ ? Set.of()
+ : provider.methodTypes(),
+ usable,
+ anotherUsableMethod);
+ })
+ .toList();
+
+ Set linkedProviderCodes = bindings.stream()
+ .map(IdentityBinding::getProviderCode)
+ .collect(Collectors.toSet());
+ List available =
+ readyProviders.values()
+ .stream()
+ .filter(provider ->
+ !linkedProviderCodes.contains(
+ provider.providerCode()))
+ .filter(provider ->
+ provider.methodTypes().contains(
+ IdentityProviderLoginMethodType
+ .OAUTH_REDIRECT)
+ || provider.methodTypes().contains(
+ IdentityProviderLoginMethodType
+ .DIRECT_PASSWORD))
+ .sorted(Comparator.comparing(
+ ReadyProvider::providerCode))
+ .map(provider ->
+ new IdentityLinkProviderView(
+ provider.providerCode(),
+ provider.displayName(),
+ provider.methodTypes()))
+ .toList();
+ return new IdentityLinkAccountState(
+ localPasswordEnabled,
+ linked,
+ available);
+ }
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void recordRejectedAfterRollback(
+ IdentityLinkActor actor,
+ UUID intentId,
+ IdentityLinkFailureCode code) {
+ requestRepository.findById(intentId)
+ .filter(request -> request.getPrimaryUserId()
+ .equals(actor.userId()))
+ .filter(request -> stateHasher.matches(
+ actor.sessionNonce(),
+ request.getStateHash()))
+ .ifPresent(request -> recordAudit(
+ actor,
+ "IDENTITY_LINK_INTENT_REJECTED",
+ request,
+ code.name().toLowerCase(Locale.ROOT)));
+ }
+
+ private IdentityLinkRequest requireRequest(
+ IdentityLinkActor actor,
+ UUID intentId) {
+ IdentityLinkRequest request = requestRepository
+ .findByIdForUpdate(intentId)
+ .orElseThrow(() ->
+ failure(
+ IdentityLinkFailureCode
+ .INTENT_NOT_FOUND));
+ if (!request.getPrimaryUserId().equals(actor.userId())) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.INTENT_NOT_FOUND);
+ }
+ if (!stateHasher.matches(
+ actor.sessionNonce(),
+ request.getStateHash())) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.SESSION_MISMATCH);
+ }
+ return request;
+ }
+
+ private void requireActive(
+ IdentityLinkRequest request,
+ IdentityLinkActor actor) {
+ if (request.getStatus() == IdentityLinkRequestStatus.EXPIRED) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.INTENT_EXPIRED);
+ }
+ if (!request.getStatus().isActive()) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.ALREADY_CONSUMED);
+ }
+ if (request.isExpiredAt(now())) {
+ request.expire(now());
+ recordAudit(
+ actor,
+ "IDENTITY_LINK_INTENT_EXPIRED",
+ request,
+ "expired");
+ throw failure(
+ IdentityLinkFailureCode.INTENT_EXPIRED);
+ }
+ }
+
+ private void requirePendingReauthentication(
+ IdentityLinkRequest request,
+ IdentityLinkActor actor) {
+ requireActive(request, actor);
+ if (request.getStatus()
+ != IdentityLinkRequestStatus
+ .PENDING_REAUTHENTICATION) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.ALREADY_CONSUMED);
+ }
+ }
+
+ private void requireReady(
+ IdentityLinkRequest request,
+ IdentityLinkActor actor,
+ IdentityLinkOperation operation) {
+ requireActive(request, actor);
+ if (request.getOperation() != operation) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.INVALID_OPERATION);
+ }
+ if (request.getStatus() != IdentityLinkRequestStatus.READY) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.REAUTHENTICATION_REQUIRED);
+ }
+ }
+
+ private UserAccount requireEligibleAccount(String userId) {
+ UserAccount user = userRepository.findByIdForUpdate(userId)
+ .orElseThrow(() ->
+ failure(
+ IdentityLinkFailureCode
+ .ACCOUNT_NOT_ELIGIBLE));
+ if (accountLoginGuard.evaluateInteractive(user)
+ != AccountLoginDecision.ALLOWED) {
+ throw failure(
+ IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE);
+ }
+ return user;
+ }
+
+ private UserAccount requireEligibleAccountForRead(String userId) {
+ UserAccount user = userRepository.findById(userId)
+ .orElseThrow(() ->
+ failure(
+ IdentityLinkFailureCode
+ .ACCOUNT_NOT_ELIGIBLE));
+ if (accountLoginGuard.evaluateInteractive(user)
+ != AccountLoginDecision.ALLOWED) {
+ throw failure(
+ IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE);
+ }
+ return user;
+ }
+
+ private void requireReadyLinkProvider(String providerCode) {
+ if (providerCode == null || providerCode.isBlank()) {
+ throw failure(
+ IdentityLinkFailureCode.INVALID_OPERATION);
+ }
+ ReadyProvider provider = readyProviders().get(providerCode);
+ if (provider == null
+ || (provider.methodTypes().stream().noneMatch(type ->
+ type == IdentityProviderLoginMethodType.OAUTH_REDIRECT
+ || type == IdentityProviderLoginMethodType
+ .DIRECT_PASSWORD))) {
+ throw failure(
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
+ }
+ }
+
+ private void requireProviderCapability(
+ IdentityLinkActor actor,
+ IdentityLinkRequest request,
+ String providerCode,
+ IdentityProviderLoginMethodType methodType) {
+ ReadyProvider provider =
+ readyProviders().get(providerCode);
+ if (provider == null
+ || !provider.methodTypes().contains(methodType)) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
+ }
+ }
+
+ private Map readyProviders() {
+ Map accumulated =
+ new LinkedHashMap<>();
+ for (IdentityProviderLoginMethod method
+ : providerRegistry.listReadyLoginMethods()) {
+ accumulated.computeIfAbsent(
+ method.providerCode(),
+ ignored -> new ProviderAccumulator(
+ method.providerCode(),
+ method.displayName()))
+ .methodTypes()
+ .add(method.methodType());
+ }
+ LinkedHashMap providers =
+ new LinkedHashMap<>();
+ accumulated.values().forEach(provider ->
+ providers.put(
+ provider.providerCode(),
+ new ReadyProvider(
+ provider.providerCode(),
+ provider.displayName(),
+ Set.copyOf(
+ provider.methodTypes()))));
+ return Map.copyOf(providers);
+ }
+
+ private IdentityBinding resolveAuthenticatedBinding(
+ IdentityAssertion assertion,
+ ProviderDescriptor descriptor) {
+ List typedMatches =
+ subjectRepository.findMatchingSubjects(
+ assertion.provider().providerCode(),
+ subjectValuesByType(assertion.allSubjects()));
+ ExternalSubject legacySubject = assertion.requireUniqueSubject(
+ descriptor.legacyPrimarySubjectType());
+ IdentityBinding legacyMatch = bindingRepository
+ .findByProviderCodeAndSubjectAndStatus(
+ assertion.provider().providerCode(),
+ legacySubject.value(),
+ IdentityBindingStatus.ACTIVE)
+ .orElse(null);
+
+ LinkedHashSet activeBindingIds = typedMatches.stream()
+ .filter(subject -> subject.getStatus()
+ == IdentityBindingSubjectStatus.ACTIVE)
+ .map(IdentityBindingSubject::getBindingId)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ if (legacyMatch != null
+ && legacyMatch.getStatus()
+ == IdentityBindingStatus.ACTIVE) {
+ activeBindingIds.add(legacyMatch.getId());
+ }
+ if (activeBindingIds.size() != 1) {
+ throw failure(
+ IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE);
+ }
+ return bindingRepository
+ .findByIdAndStatusForUpdate(
+ activeBindingIds.getFirst(),
+ IdentityBindingStatus.ACTIVE)
+ .orElseThrow(() ->
+ failure(
+ IdentityLinkFailureCode
+ .ACCOUNT_NOT_ELIGIBLE));
+ }
+
+ private void requireSubjectsUnbound(
+ IdentityLinkActor actor,
+ IdentityLinkRequest request,
+ IdentityAssertion assertion,
+ ProviderDescriptor descriptor) {
+ List matches =
+ subjectRepository.findMatchingSubjects(
+ assertion.provider().providerCode(),
+ subjectValuesByType(assertion.allSubjects()));
+ ExternalSubject legacySubject = assertion.requireUniqueSubject(
+ descriptor.legacyPrimarySubjectType());
+ boolean activeSubjectExists = matches.stream()
+ .anyMatch(subject -> subject.getStatus()
+ == IdentityBindingSubjectStatus.ACTIVE);
+ boolean activeLegacyBindingExists = bindingRepository
+ .findByProviderCodeAndSubjectAndStatus(
+ assertion.provider().providerCode(),
+ legacySubject.value(),
+ IdentityBindingStatus.ACTIVE)
+ .isPresent();
+ if (activeSubjectExists || activeLegacyBindingExists) {
+ throw reject(
+ actor,
+ request,
+ IdentityLinkFailureCode.IDENTITY_IN_USE);
+ }
+ }
+
+ private void requireNoActiveRequest(
+ IdentityLinkActor actor,
+ Instant now) {
+ requestRepository
+ .findActiveByPrimaryUserIdForUpdate(
+ actor.userId(),
+ Set.of(
+ IdentityLinkRequestStatus
+ .PENDING_REAUTHENTICATION,
+ IdentityLinkRequestStatus.READY))
+ .ifPresent(active -> {
+ if (!active.isExpiredAt(now)) {
+ throw reject(
+ actor,
+ active,
+ IdentityLinkFailureCode
+ .ACTIVE_INTENT_EXISTS);
+ }
+ active.expire(now);
+ recordAudit(
+ actor,
+ "IDENTITY_LINK_INTENT_EXPIRED",
+ active,
+ "expired");
+ requestRepository.flush();
+ });
+ }
+
+ private boolean hasOtherUsableLoginMethod(
+ String userId,
+ long excludedBindingId) {
+ if (credentialRepository.existsByUserId(userId)) {
+ return true;
+ }
+ Set readyProviderCodes =
+ readyProviders().keySet();
+ return bindingRepository
+ .findByUserIdAndStatus(
+ userId,
+ IdentityBindingStatus.ACTIVE)
+ .stream()
+ .filter(binding -> binding.getId()
+ != excludedBindingId)
+ .map(IdentityBinding::getProviderCode)
+ .anyMatch(readyProviderCodes::contains);
+ }
+
+ private Map> subjectValuesByType(
+ Set subjects) {
+ LinkedHashMap> valuesByType =
+ new LinkedHashMap<>();
+ for (ExternalSubject subject : subjects) {
+ valuesByType.computeIfAbsent(
+ subject.type(),
+ ignored -> new LinkedHashSet<>())
+ .add(subject.value());
+ }
+ return Map.copyOf(valuesByType);
+ }
+
+ private void recordAudit(
+ IdentityLinkActor actor,
+ String action,
+ IdentityLinkRequest request,
+ String result) {
+ IdentityLoginContext context = actor.auditContext();
+ auditLogService.record(
+ actor.userId(),
+ action,
+ "IDENTITY_LINK_REQUEST",
+ null,
+ context.requestId(),
+ context.clientIp(),
+ context.userAgent(),
+ "{\"intentId\":\""
+ + request.getId()
+ + "\",\"operation\":\""
+ + request.getOperation()
+ + "\",\"providerCode\":\""
+ + request.getProviderCode()
+ + "\",\"result\":\""
+ + result
+ + "\"}");
+ }
+
+ private IdentityLinkIntent toIntent(IdentityLinkRequest request) {
+ return new IdentityLinkIntent(
+ request.getId(),
+ request.getOperation(),
+ request.getStatus(),
+ request.getProviderCode(),
+ request.getTargetBindingId(),
+ request.getExpiresAt());
+ }
+
+ private Instant now() {
+ return Instant.now(clock);
+ }
+
+ private IdentityLinkException failure(
+ IdentityLinkFailureCode code) {
+ return new IdentityLinkException(code);
+ }
+
+ private IdentityLinkException reject(
+ IdentityLinkActor actor,
+ IdentityLinkRequest request,
+ IdentityLinkFailureCode code) {
+ recordAudit(
+ actor,
+ "IDENTITY_LINK_INTENT_REJECTED",
+ request,
+ code.name().toLowerCase(Locale.ROOT));
+ return failure(code);
+ }
+
+ record LinkedBinding(
+ PlatformPrincipal principal,
+ long bindingId) {
+ }
+
+ private record ReadyProvider(
+ String providerCode,
+ String displayName,
+ Set methodTypes) {
+ }
+
+ private record ProviderAccumulator(
+ String providerCode,
+ String displayName,
+ Set methodTypes) {
+ private ProviderAccumulator(
+ String providerCode,
+ String displayName) {
+ this(
+ providerCode,
+ displayName,
+ new LinkedHashSet<>());
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java
index 5496e010..6192f21c 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java
@@ -131,9 +131,10 @@ class IdentityResolutionTransaction {
assertion.provider().providerCode(),
subjectValuesByType(assertion.allSubjects()));
IdentityBinding legacyMatch = bindingRepository
- .findByProviderCodeAndSubject(
+ .findByProviderCodeAndSubjectAndStatus(
assertion.provider().providerCode(),
- legacySubject.value())
+ legacySubject.value(),
+ IdentityBindingStatus.ACTIVE)
.orElse(null);
LinkedHashSet activeBindingIds = typedMatches.stream()
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java
index e1ebdb86..6c63b374 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java
@@ -110,10 +110,11 @@ public class LocalAuthService {
* Authenticates a local account and returns the principal snapshot used to
* establish a web session.
*/
- @Transactional
+ @Transactional(noRollbackFor = AuthFlowException.class)
public PlatformPrincipal login(String username, String password) {
String normalizedUsername = normalizeUsername(username);
- LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername)
+ LocalCredential credential = credentialRepository
+ .findByUsernameIgnoreCaseForUpdate(normalizedUsername)
.orElse(null);
if (credential == null) {
@@ -139,6 +140,39 @@ public class LocalAuthService {
return principalFactory.create(user, "local");
}
+ /**
+ * Reauthenticates the already authenticated account without creating,
+ * replacing, or rotating its web session.
+ */
+ @Transactional(noRollbackFor = AuthFlowException.class)
+ public PlatformPrincipal reauthenticate(
+ String userId,
+ String password) {
+ LocalCredential credential = credentialRepository
+ .findByUserIdForUpdate(userId)
+ .orElseThrow(() -> new AuthFlowException(
+ HttpStatus.BAD_REQUEST,
+ "error.auth.local.notEnabled"));
+ UserAccount user = userAccountRepository.findById(userId)
+ .orElseThrow(() -> new IllegalStateException(
+ "User not found for local credential"));
+
+ requireLocalLoginAllowed(
+ accountLoginGuard.evaluateInteractive(user));
+ ensureNotLocked(credential);
+ if (!passwordEncoder.matches(
+ password == null ? "" : password,
+ credential.getPasswordHash())) {
+ handleFailedLogin(credential);
+ throw invalidCredentials();
+ }
+
+ credential.setFailedAttempts(0);
+ credential.setLockedUntil(null);
+ credentialRepository.save(credential);
+ return principalFactory.create(user, "local");
+ }
+
/**
* Changes the stored password for an already authenticated local account.
*/
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java
index a80d44ac..b55e67f9 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java
@@ -1,7 +1,11 @@
package com.iflytek.skillhub.auth.local;
+import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
/**
@@ -14,6 +18,24 @@ public interface LocalCredentialRepository extends JpaRepository findByUserId(String userId);
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("""
+ select credential
+ from LocalCredential credential
+ where lower(credential.username) = lower(:username)
+ """)
+ Optional findByUsernameIgnoreCaseForUpdate(
+ @Param("username") String username);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("""
+ select credential
+ from LocalCredential credential
+ where credential.userId = :userId
+ """)
+ Optional findByUserIdForUpdate(
+ @Param("userId") String userId);
+
boolean existsByUsernameIgnoreCase(String username);
boolean existsByUserId(String userId);
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java
index 8ead2c02..c1386f6a 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java
@@ -3,6 +3,7 @@ package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService;
+import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -29,12 +30,15 @@ public final class IdentityProviderRouteReadinessFilter
"/login/oauth2/code/";
private final ClientRegistrationRepository registrationRepository;
private final IdentityProviderReadinessService readinessService;
+ private final OAuth2LoginFailureHandler failureHandler;
public IdentityProviderRouteReadinessFilter(
ClientRegistrationRepository registrationRepository,
- IdentityProviderReadinessService readinessService) {
+ IdentityProviderReadinessService readinessService,
+ OAuth2LoginFailureHandler failureHandler) {
this.registrationRepository = registrationRepository;
this.readinessService = readinessService;
+ this.failureHandler = failureHandler;
}
@Override
@@ -55,6 +59,9 @@ public final class IdentityProviderRouteReadinessFilter
? null
: registrationRepository.findByRegistrationId(registrationId);
if (registration == null) {
+ if (redirectIdentityLinkFailure(request, response)) {
+ return;
+ }
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
@@ -62,6 +69,9 @@ public final class IdentityProviderRouteReadinessFilter
try {
readinessService.requireReady(registration);
} catch (IdentityCoreException exception) {
+ if (redirectIdentityLinkFailure(request, response)) {
+ return;
+ }
int status = exception.getReasonCode()
== IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH
? HttpServletResponse.SC_SERVICE_UNAVAILABLE
@@ -77,6 +87,9 @@ public final class IdentityProviderRouteReadinessFilter
"Identity provider route '{}' readiness check failed",
registration.getRegistrationId(),
exception);
+ if (redirectIdentityLinkFailure(request, response)) {
+ return;
+ }
response.setStatus(
HttpServletResponse.SC_SERVICE_UNAVAILABLE);
return;
@@ -84,6 +97,16 @@ public final class IdentityProviderRouteReadinessFilter
filterChain.doFilter(request, response);
}
+ private boolean redirectIdentityLinkFailure(
+ HttpServletRequest request,
+ HttpServletResponse response)
+ throws IOException {
+ return failureHandler.redirectIdentityLinkRouteFailure(
+ request,
+ response,
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
+ }
+
private String registrationId(HttpServletRequest request) {
String path = pathWithinApplication(request);
String value = pathSegment(path, AUTHORIZATION_PREFIX);
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java
index 14beac75..413622d4 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java
@@ -1,33 +1,88 @@
package com.iflytek.skillhub.auth.oauth;
+import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
+import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
-import java.io.IOException;
-
/**
* Failure handler for OAuth logins that normalizes policy and account-state
* failures into predictable user-facing redirects.
*/
@Component
-public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
+public class OAuth2LoginFailureHandler
+ extends SimpleUrlAuthenticationFailureHandler {
private final OAuthLoginFlowService oauthLoginFlowService;
+ private final IdentityLinkSessionManager identityLinkSessionManager;
- public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) {
+ public OAuth2LoginFailureHandler(
+ OAuthLoginFlowService oauthLoginFlowService,
+ IdentityLinkSessionManager identityLinkSessionManager) {
this.oauthLoginFlowService = oauthLoginFlowService;
+ this.identityLinkSessionManager = identityLinkSessionManager;
+ }
+
+ /**
+ * Converts a pre-upstream route failure into an Identity Link callback
+ * result only when this session actually owns a pending browser flow.
+ * Normal OAuth login readiness failures retain their existing HTTP
+ * status behavior.
+ */
+ public boolean redirectIdentityLinkRouteFailure(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ IdentityLinkFailureCode reasonCode)
+ throws IOException {
+ var session = request.getSession(false);
+ var intentId = identityLinkSessionManager
+ .consumeFailedBrowserFlow(session);
+ if (intentId.isEmpty()) {
+ return false;
+ }
+ oauthLoginFlowService.consumeReturnTo(session);
+ getRedirectStrategy().sendRedirect(
+ request,
+ response,
+ "/settings/security?identityLink=failed"
+ + "&intentId="
+ + intentId.get()
+ + "&reasonCode="
+ + reasonCode.name());
+ return true;
}
@Override
- public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
- AuthenticationException exception)
+ public void onAuthenticationFailure(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException exception)
throws IOException, ServletException {
- String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false));
- String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo);
+ var session = request.getSession(false);
+ String returnTo = oauthLoginFlowService.consumeReturnTo(session);
+ String reasonCode = oauthLoginFlowService
+ .identityLinkFailureReasonCode(exception)
+ .orElse(
+ IdentityLinkFailureCode
+ .PROVIDER_AUTHENTICATION_FAILED
+ .name());
+ String redirectTarget = identityLinkSessionManager
+ .consumeFailedBrowserFlow(session)
+ .map(intentId ->
+ "/settings/security?identityLink=failed"
+ + "&intentId="
+ + intentId
+ + "&reasonCode="
+ + reasonCode)
+ .orElseGet(() ->
+ oauthLoginFlowService.resolveFailureRedirect(
+ exception,
+ returnTo));
if (redirectTarget != null) {
getRedirectStrategy().sendRedirect(request, response, redirectTarget);
return;
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java
index 2940d02e..225300be 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java
@@ -1,8 +1,15 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
+import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
+import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow;
+import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase;
+import com.iflytek.skillhub.auth.identity.IdentityLinkException;
+import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
+import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome;
+import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
@@ -16,6 +23,8 @@ import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
@@ -28,6 +37,8 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
/**
* Flow owner for browser OAuth login. It centralizes the stages of remembering
@@ -41,15 +52,21 @@ public class OAuthLoginFlowService {
private final Map extractors;
private final TrustedProviderRouteResolver providerRouteResolver;
private final ExternalIdentityLoginService identityLoginService;
+ private final ExternalIdentityLinkService identityLinkService;
+ private final IdentityLinkSessionManager identityLinkSessionManager;
@Autowired
public OAuthLoginFlowService(List extractorList,
TrustedProviderRouteResolver providerRouteResolver,
- ExternalIdentityLoginService identityLoginService) {
+ ExternalIdentityLoginService identityLoginService,
+ ExternalIdentityLinkService identityLinkService,
+ IdentityLinkSessionManager identityLinkSessionManager) {
this(
extractorList,
providerRouteResolver,
identityLoginService,
+ identityLinkService,
+ identityLinkSessionManager,
new DefaultOAuth2UserService());
}
@@ -57,6 +74,8 @@ public class OAuthLoginFlowService {
List extractorList,
TrustedProviderRouteResolver providerRouteResolver,
ExternalIdentityLoginService identityLoginService,
+ ExternalIdentityLinkService identityLinkService,
+ IdentityLinkSessionManager identityLinkSessionManager,
OAuth2UserService delegate) {
this.extractors = extractorList.stream()
.collect(Collectors.toMap(
@@ -64,6 +83,8 @@ public class OAuthLoginFlowService {
Function.identity()));
this.providerRouteResolver = providerRouteResolver;
this.identityLoginService = identityLoginService;
+ this.identityLinkService = identityLinkService;
+ this.identityLinkSessionManager = identityLinkSessionManager;
this.delegate = Objects.requireNonNull(delegate, "delegate");
}
@@ -116,6 +137,14 @@ public class OAuthLoginFlowService {
ProviderAuthenticationResult result,
IdentityLoginContext context) {
try {
+ Optional identityLinkFlow =
+ consumeIdentityLinkFlow(provider, context);
+ if (identityLinkFlow.isPresent()) {
+ return authenticateIdentityLinkFlow(
+ identityLinkFlow.get(),
+ provider,
+ result);
+ }
IdentityLoginOutcome outcome = identityLoginService.authenticate(
provider,
result,
@@ -132,9 +161,69 @@ public class OAuthLoginFlowService {
null));
} catch (IdentityCoreException exception) {
throw mapIdentityFailure(exception);
+ } catch (IdentityLinkException exception) {
+ throw oauthFailure(
+ "identity_link_failed",
+ exception.getReasonCode().name(),
+ exception);
}
}
+ private Optional consumeIdentityLinkFlow(
+ ResolvedProviderHandle provider,
+ IdentityLoginContext context) {
+ if (!(RequestContextHolder.getRequestAttributes()
+ instanceof ServletRequestAttributes attributes)) {
+ return Optional.empty();
+ }
+ return identityLinkSessionManager.consumeBrowserFlow(
+ attributes.getRequest(),
+ provider.providerCode(),
+ context);
+ }
+
+ private PlatformPrincipal authenticateIdentityLinkFlow(
+ IdentityLinkBrowserFlow flow,
+ ResolvedProviderHandle provider,
+ ProviderAuthenticationResult result) {
+ IdentityLinkOutcome outcome;
+ if (flow.phase()
+ == IdentityLinkBrowserPhase.REAUTHENTICATE) {
+ outcome = identityLinkService.reauthenticate(
+ flow.actor(),
+ flow.intentId(),
+ provider,
+ result);
+ } else {
+ outcome = identityLinkService.link(
+ flow.actor(),
+ flow.intentId(),
+ provider,
+ result);
+ }
+ if (outcome
+ instanceof IdentityLinkOutcome.Reauthenticated completed) {
+ return completed.principal();
+ }
+ if (outcome instanceof IdentityLinkOutcome.Linked linked) {
+ currentRequest().ifPresent(request ->
+ identityLinkSessionManager.remove(
+ request.getSession(false),
+ flow.intentId()));
+ return linked.principal();
+ }
+ throw new IllegalStateException(
+ "Unsupported identity link outcome");
+ }
+
+ private Optional currentRequest() {
+ if (RequestContextHolder.getRequestAttributes()
+ instanceof ServletRequestAttributes attributes) {
+ return Optional.of(attributes.getRequest());
+ }
+ return Optional.empty();
+ }
+
public void rememberReturnTo(HttpServletRequest request) {
String returnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(request.getParameter("returnTo"));
HttpSession session = request.getSession();
@@ -178,12 +267,78 @@ public class OAuthLoginFlowService {
oauth2Exception.getError().getErrorCode()))) {
return "/access-denied";
}
+ if (exception instanceof OAuth2AuthenticationException oauth2Exception
+ && "identity_link_failed".equals(
+ oauth2Exception.getError().getErrorCode())) {
+ return identityLinkFailureRedirect(
+ returnTo,
+ identityLinkFailureReasonCode(exception)
+ .orElse(null));
+ }
if (returnTo != null) {
return "/login?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8);
}
return null;
}
+ public Optional identityLinkFailureReasonCode(
+ AuthenticationException exception) {
+ if (!(exception
+ instanceof OAuth2AuthenticationException oauth2Exception)
+ || !"identity_link_failed".equals(
+ oauth2Exception.getError().getErrorCode())) {
+ return Optional.empty();
+ }
+ String description =
+ oauth2Exception.getError().getDescription();
+ try {
+ return Optional.of(
+ IdentityLinkFailureCode.valueOf(
+ description).name());
+ } catch (IllegalArgumentException | NullPointerException ignored) {
+ return Optional.empty();
+ }
+ }
+
+ private String identityLinkFailureRedirect(
+ String returnTo,
+ String reasonCode) {
+ Optional intentId = identityLinkIntentId(returnTo);
+ return "/settings/security?identityLink=failed"
+ + intentId.map(id -> "&intentId=" + id)
+ .orElse("")
+ + (reasonCode == null
+ ? ""
+ : "&reasonCode="
+ + URLEncoder.encode(
+ reasonCode,
+ StandardCharsets.UTF_8));
+ }
+
+ private Optional identityLinkIntentId(String returnTo) {
+ if (returnTo == null
+ || !returnTo.startsWith("/settings/security?")) {
+ return Optional.empty();
+ }
+ String query = returnTo.substring(
+ returnTo.indexOf('?') + 1);
+ for (String parameter : query.split("&")) {
+ int separator = parameter.indexOf('=');
+ if (separator <= 0
+ || !"intentId".equals(
+ parameter.substring(0, separator))) {
+ continue;
+ }
+ try {
+ return Optional.of(UUID.fromString(
+ parameter.substring(separator + 1)));
+ } catch (IllegalArgumentException ignored) {
+ return Optional.empty();
+ }
+ }
+ return Optional.empty();
+ }
+
public record AuthenticatedLoginContext(OAuth2User upstreamUser, PlatformPrincipal principal) {
}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java
index c72b1d9d..3899c6eb 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java
@@ -1,5 +1,6 @@
package com.iflytek.skillhub.auth.oauth;
+import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
@@ -16,27 +17,45 @@ public class SkillHubOAuth2AuthorizationRequestResolver
private final DefaultOAuth2AuthorizationRequestResolver delegate;
private final OAuthLoginFlowService oauthLoginFlowService;
+ private final IdentityLinkSessionManager identityLinkSessionManager;
public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
- OAuthLoginFlowService oauthLoginFlowService) {
+ OAuthLoginFlowService oauthLoginFlowService,
+ IdentityLinkSessionManager identityLinkSessionManager) {
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
this.oauthLoginFlowService = oauthLoginFlowService;
+ this.identityLinkSessionManager = identityLinkSessionManager;
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request);
- oauthLoginFlowService.rememberReturnTo(request);
+ rememberAuthorizationFlow(request, authorizationRequest);
return authorizationRequest;
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId);
- oauthLoginFlowService.rememberReturnTo(request);
+ rememberAuthorizationFlow(request, authorizationRequest);
return authorizationRequest;
}
+
+ private void rememberAuthorizationFlow(
+ HttpServletRequest request,
+ OAuth2AuthorizationRequest authorizationRequest) {
+ if (authorizationRequest == null) {
+ return;
+ }
+ oauthLoginFlowService.rememberReturnTo(request);
+ String registrationId = authorizationRequest.getAttribute(
+ "registration_id");
+ identityLinkSessionManager.activateBrowserFlow(
+ request.getSession(false),
+ registrationId,
+ authorizationRequest.getState());
+ }
}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java
index fc0414a5..49492c23 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java
@@ -16,7 +16,10 @@ import org.springframework.stereotype.Repository;
*/
@Repository
public interface IdentityBindingRepository extends JpaRepository {
- Optional findByProviderCodeAndSubject(String providerCode, String subject);
+ Optional findByProviderCodeAndSubjectAndStatus(
+ String providerCode,
+ String subject,
+ IdentityBindingStatus status);
@Query("""
select distinct binding.providerCode
@@ -39,4 +42,8 @@ public interface IdentityBindingRepository extends JpaRepository findByUserId(String userId);
+
+ List findByUserIdAndStatus(
+ String userId,
+ IdentityBindingStatus status);
}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityLinkRequestRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityLinkRequestRepository.java
new file mode 100644
index 00000000..5545e2ba
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityLinkRequestRepository.java
@@ -0,0 +1,39 @@
+package com.iflytek.skillhub.auth.repository;
+
+import com.iflytek.skillhub.auth.entity.IdentityLinkRequest;
+import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
+import jakarta.persistence.LockModeType;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.UUID;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface IdentityLinkRequestRepository
+ extends JpaRepository {
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("""
+ select request
+ from IdentityLinkRequest request
+ where request.id = :requestId
+ """)
+ Optional findByIdForUpdate(
+ @Param("requestId") UUID requestId);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("""
+ select request
+ from IdentityLinkRequest request
+ where request.primaryUserId = :userId
+ and request.status in :statuses
+ """)
+ Optional findActiveByPrimaryUserIdForUpdate(
+ @Param("userId") String userId,
+ @Param("statuses")
+ Collection statuses);
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestTest.java
new file mode 100644
index 00000000..d00fa8cc
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestTest.java
@@ -0,0 +1,95 @@
+package com.iflytek.skillhub.auth.entity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.Instant;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+class IdentityLinkRequestTest {
+
+ private static final Instant CREATED_AT =
+ Instant.parse("2026-07-31T08:00:00Z");
+ private static final String STATE_HASH = "a".repeat(64);
+
+ @Test
+ void linkRequestRequiresNoTargetBindingAndStartsPending() {
+ IdentityLinkRequest request = new IdentityLinkRequest(
+ UUID.randomUUID(),
+ "usr_1",
+ IdentityLinkOperation.LINK,
+ "github",
+ null,
+ STATE_HASH,
+ CREATED_AT.plusSeconds(600),
+ CREATED_AT);
+
+ assertThat(request.getStatus())
+ .isEqualTo(
+ IdentityLinkRequestStatus
+ .PENDING_REAUTHENTICATION);
+ assertThat(request.getTargetBindingId()).isNull();
+ }
+
+ @Test
+ void unlinkRequestRequiresTargetBinding() {
+ assertThatThrownBy(() ->
+ new IdentityLinkRequest(
+ UUID.randomUUID(),
+ "usr_1",
+ IdentityLinkOperation.UNLINK,
+ "github",
+ null,
+ STATE_HASH,
+ CREATED_AT.plusSeconds(600),
+ CREATED_AT))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void requestCanOnlyBeReauthenticatedAndCompletedOnce() {
+ IdentityLinkRequest request = request();
+ request.markReauthenticated(
+ "local-password",
+ CREATED_AT.plusSeconds(10));
+ request.complete(CREATED_AT.plusSeconds(20));
+
+ assertThat(request.getStatus())
+ .isEqualTo(IdentityLinkRequestStatus.COMPLETED);
+ assertThatThrownBy(() ->
+ request.complete(CREATED_AT.plusSeconds(30)))
+ .isInstanceOf(IllegalStateException.class);
+ assertThatThrownBy(() ->
+ request.markReauthenticated(
+ "local-password",
+ CREATED_AT.plusSeconds(30)))
+ .isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ void expiredRequestCannotReturnToAnActiveState() {
+ IdentityLinkRequest request = request();
+ request.expire(CREATED_AT.plusSeconds(600));
+
+ assertThat(request.getStatus())
+ .isEqualTo(IdentityLinkRequestStatus.EXPIRED);
+ assertThatThrownBy(() ->
+ request.markReauthenticated(
+ "local-password",
+ CREATED_AT.plusSeconds(601)))
+ .isInstanceOf(IllegalStateException.class);
+ }
+
+ private IdentityLinkRequest request() {
+ return new IdentityLinkRequest(
+ UUID.randomUUID(),
+ "usr_1",
+ IdentityLinkOperation.LINK,
+ "github",
+ null,
+ STATE_HASH,
+ CREATED_AT.plusSeconds(600),
+ CREATED_AT);
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentServiceTest.java
new file mode 100644
index 00000000..63b440fb
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentServiceTest.java
@@ -0,0 +1,123 @@
+package com.iflytek.skillhub.auth.identity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
+import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
+import com.iflytek.skillhub.auth.local.LocalAuthService;
+import java.time.Instant;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class IdentityLinkIntentServiceTest {
+
+ @Mock
+ private IdentityLinkTransaction transaction;
+
+ @Mock
+ private LocalAuthService localAuthService;
+
+ @Test
+ void localReauthenticationValidatesIntentBeforeCheckingPassword() {
+ IdentityLinkIntentService service =
+ new IdentityLinkIntentService(
+ transaction,
+ localAuthService);
+ IdentityLinkActor actor = actor();
+ UUID intentId = UUID.randomUUID();
+ IdentityLinkIntent pending = intent(
+ intentId,
+ IdentityLinkRequestStatus.PENDING_REAUTHENTICATION);
+ IdentityLinkIntent ready = intent(
+ intentId,
+ IdentityLinkRequestStatus.READY);
+ when(transaction.getIntent(actor, intentId))
+ .thenReturn(pending);
+ when(transaction.markLocalReauthenticated(
+ actor,
+ intentId))
+ .thenReturn(ready);
+
+ IdentityLinkIntent result = service.reauthenticateLocal(
+ actor,
+ intentId,
+ "current-password");
+
+ assertThat(result).isSameAs(ready);
+ InOrder order = inOrder(
+ transaction,
+ localAuthService);
+ order.verify(transaction).getIntent(actor, intentId);
+ order.verify(localAuthService).reauthenticate(
+ actor.userId(),
+ "current-password");
+ order.verify(transaction).markLocalReauthenticated(
+ actor,
+ intentId);
+ }
+
+ @Test
+ void consumedIntentDoesNotCheckPassword() {
+ IdentityLinkIntentService service =
+ new IdentityLinkIntentService(
+ transaction,
+ localAuthService);
+ IdentityLinkActor actor = actor();
+ UUID intentId = UUID.randomUUID();
+ when(transaction.getIntent(actor, intentId))
+ .thenReturn(intent(
+ intentId,
+ IdentityLinkRequestStatus.READY));
+
+ assertThatThrownBy(() ->
+ service.reauthenticateLocal(
+ actor,
+ intentId,
+ "current-password"))
+ .isInstanceOfSatisfying(
+ IdentityLinkException.class,
+ exception -> assertThat(
+ exception.getReasonCode())
+ .isEqualTo(
+ IdentityLinkFailureCode
+ .ALREADY_CONSUMED));
+
+ verifyNoInteractions(localAuthService);
+ verify(transaction, never())
+ .markLocalReauthenticated(actor, intentId);
+ }
+
+ private IdentityLinkActor actor() {
+ return new IdentityLinkActor(
+ "usr_1",
+ "local",
+ "session-nonce",
+ new IdentityLoginContext(
+ "req-1",
+ "203.0.113.9",
+ "Identity Link Test"));
+ }
+
+ private IdentityLinkIntent intent(
+ UUID intentId,
+ IdentityLinkRequestStatus status) {
+ return new IdentityLinkIntent(
+ intentId,
+ IdentityLinkOperation.LINK,
+ status,
+ "github",
+ null,
+ Instant.parse("2026-07-31T08:10:00Z"));
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManagerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManagerTest.java
new file mode 100644
index 00000000..1bf78757
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManagerTest.java
@@ -0,0 +1,209 @@
+package com.iflytek.skillhub.auth.identity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpSession;
+
+class IdentityLinkSessionManagerTest {
+
+ private static final Clock CLOCK = Clock.fixed(
+ Instant.parse("2026-07-31T08:00:00Z"),
+ ZoneOffset.UTC);
+ private static final IdentityLoginContext CONTEXT =
+ new IdentityLoginContext(
+ "req-1",
+ "203.0.113.9",
+ "Browser");
+
+ private IdentityLinkSessionManager manager;
+ private MockHttpSession session;
+
+ @BeforeEach
+ void setUp() {
+ manager = new IdentityLinkSessionManager(
+ new SecureRandom(),
+ new IdentityLinkStateHasher(),
+ CLOCK);
+ session = new MockHttpSession();
+ session.setAttribute(
+ "platformPrincipal",
+ new PlatformPrincipal(
+ "usr_1",
+ "Alice",
+ "alice@example.com",
+ null,
+ "local",
+ Set.of("USER")));
+ }
+
+ @Test
+ void generatedNonceStaysInSessionAndIsOmittedFromActorString() {
+ UUID intentId = UUID.randomUUID();
+
+ IdentityLinkActor actor = manager.start(
+ session,
+ intentId,
+ CONTEXT);
+
+ assertThat(actor.userId()).isEqualTo("usr_1");
+ assertThat(actor.toString())
+ .contains("usr_1")
+ .doesNotContain("nonce");
+ assertThat(manager.actor(session, intentId, CONTEXT).userId())
+ .isEqualTo("usr_1");
+ }
+
+ @Test
+ void anotherSessionCannotResumeIntent() {
+ UUID intentId = UUID.randomUUID();
+ manager.start(session, intentId, CONTEXT);
+ MockHttpSession otherSession = new MockHttpSession();
+ otherSession.setAttribute(
+ "platformPrincipal",
+ session.getAttribute("platformPrincipal"));
+
+ assertThatThrownBy(() ->
+ manager.actor(otherSession, intentId, CONTEXT))
+ .isInstanceOfSatisfying(
+ IdentityLinkException.class,
+ exception -> assertThat(
+ exception.getReasonCode())
+ .isEqualTo(
+ IdentityLinkFailureCode
+ .SESSION_MISMATCH));
+ }
+
+ @Test
+ void browserFlowIsBoundToProviderOAuthStateAndConsumedOnce() {
+ UUID intentId = UUID.randomUUID();
+ manager.start(session, intentId, CONTEXT);
+ manager.prepareBrowserFlow(
+ session,
+ intentId,
+ IdentityLinkBrowserPhase.LINK,
+ "github",
+ CONTEXT);
+ manager.activateBrowserFlow(
+ session,
+ "github",
+ "oauth-state");
+ MockHttpServletRequest callback =
+ new MockHttpServletRequest(
+ "GET",
+ "/login/oauth2/code/github");
+ callback.setSession(session);
+ callback.setParameter("state", "oauth-state");
+
+ IdentityLinkBrowserFlow flow =
+ manager.consumeBrowserFlow(
+ callback,
+ "github",
+ CONTEXT)
+ .orElseThrow();
+
+ assertThat(flow.intentId()).isEqualTo(intentId);
+ assertThat(flow.phase())
+ .isEqualTo(IdentityLinkBrowserPhase.LINK);
+ assertThat(manager.consumeBrowserFlow(
+ callback,
+ "github",
+ CONTEXT)).isEmpty();
+ }
+
+ @Test
+ void mismatchedOAuthStateFailsClosedAndCannotBeRetried() {
+ UUID intentId = UUID.randomUUID();
+ manager.start(session, intentId, CONTEXT);
+ manager.prepareBrowserFlow(
+ session,
+ intentId,
+ IdentityLinkBrowserPhase.REAUTHENTICATE,
+ "github",
+ CONTEXT);
+ manager.activateBrowserFlow(
+ session,
+ "github",
+ "expected-state");
+ MockHttpServletRequest callback =
+ new MockHttpServletRequest(
+ "GET",
+ "/login/oauth2/code/github");
+ callback.setSession(session);
+ callback.setParameter("state", "different-state");
+
+ assertThatThrownBy(() ->
+ manager.consumeBrowserFlow(
+ callback,
+ "github",
+ CONTEXT))
+ .isInstanceOfSatisfying(
+ IdentityLinkException.class,
+ exception -> assertThat(
+ exception.getReasonCode())
+ .isEqualTo(
+ IdentityLinkFailureCode
+ .SESSION_MISMATCH));
+ assertThat(manager.consumeBrowserFlow(
+ callback,
+ "github",
+ CONTEXT)).isEmpty();
+ }
+
+ @Test
+ void failedBrowserFlowKeepsIntentAndCanBeRetried() {
+ UUID intentId = UUID.randomUUID();
+ manager.start(session, intentId, CONTEXT);
+ manager.prepareBrowserFlow(
+ session,
+ intentId,
+ IdentityLinkBrowserPhase.LINK,
+ "github",
+ CONTEXT);
+ manager.activateBrowserFlow(
+ session,
+ "github",
+ "oauth-state");
+
+ assertThat(manager.consumeFailedBrowserFlow(session))
+ .contains(intentId);
+ assertThat(manager.consumeFailedBrowserFlow(session))
+ .isEmpty();
+ assertThat(manager.actor(session, intentId, CONTEXT).userId())
+ .isEqualTo("usr_1");
+
+ manager.prepareBrowserFlow(
+ session,
+ intentId,
+ IdentityLinkBrowserPhase.LINK,
+ "github",
+ CONTEXT);
+ manager.activateBrowserFlow(
+ session,
+ "github",
+ "retry-state");
+ MockHttpServletRequest retryCallback =
+ new MockHttpServletRequest(
+ "GET",
+ "/login/oauth2/code/github");
+ retryCallback.setSession(session);
+ retryCallback.setParameter("state", "retry-state");
+
+ assertThat(manager.consumeBrowserFlow(
+ retryCallback,
+ "github",
+ CONTEXT))
+ .map(IdentityLinkBrowserFlow::intentId)
+ .contains(intentId);
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java
index 862ee8e5..eda56716 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java
@@ -80,7 +80,8 @@ class IdentityResolutionTransactionTest {
auditLogService);
when(subjectRepository.findMatchingSubjects(any(), any()))
.thenReturn(List.of());
- when(bindingRepository.findByProviderCodeAndSubject(
+ when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
+ any(),
any(),
any())).thenReturn(Optional.empty());
when(userRepository.save(any(UserAccount.class)))
@@ -309,9 +310,11 @@ class IdentityResolutionTransactionTest {
"123456",
true);
UserAccount user = user("usr_1", UserStatus.ACTIVE, false);
- when(bindingRepository.findByProviderCodeAndSubject(
+ when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"github",
- "123456")).thenReturn(Optional.of(binding));
+ "123456",
+ IdentityBindingStatus.ACTIVE))
+ .thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
@@ -394,9 +397,11 @@ class IdentityResolutionTransactionTest {
when(subjectRepository.findMatchingSubjects(
org.mockito.ArgumentMatchers.eq("provider"),
any())).thenReturn(List.of(alias, stable));
- when(bindingRepository.findByProviderCodeAndSubject(
+ when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"provider",
- "legacy-123")).thenReturn(Optional.of(binding));
+ "legacy-123",
+ IdentityBindingStatus.ACTIVE))
+ .thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
@@ -507,9 +512,11 @@ class IdentityResolutionTransactionTest {
"github",
"123456");
UserAccount user = user("usr_1", UserStatus.PENDING, false);
- when(bindingRepository.findByProviderCodeAndSubject(
+ when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"github",
- "123456")).thenReturn(Optional.of(binding));
+ "123456",
+ IdentityBindingStatus.ACTIVE))
+ .thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
@@ -554,9 +561,11 @@ class IdentityResolutionTransactionTest {
"github",
"123456");
UserAccount user = user("usr_1", UserStatus.PENDING, false);
- when(bindingRepository.findByProviderCodeAndSubject(
+ when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"github",
- "123456")).thenReturn(Optional.of(binding));
+ "123456",
+ IdentityBindingStatus.ACTIVE))
+ .thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
@@ -614,9 +623,11 @@ class IdentityResolutionTransactionTest {
"github",
"123456");
UserAccount user = user("usr_blocked", status, system);
- when(bindingRepository.findByProviderCodeAndSubject(
+ when(bindingRepository.findByProviderCodeAndSubjectAndStatus(
"github",
- "123456")).thenReturn(Optional.of(binding));
+ "123456",
+ IdentityBindingStatus.ACTIVE))
+ .thenReturn(Optional.of(binding));
when(bindingRepository.findByIdAndStatusForUpdate(
1L,
IdentityBindingStatus.ACTIVE))
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java
index 36f3ccfd..1f8a7636 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java
@@ -14,6 +14,7 @@ import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.identity.AccountLoginGuard;
import com.iflytek.skillhub.auth.identity.PlatformPrincipalFactory;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
@@ -99,7 +100,7 @@ class LocalAuthServiceTest {
given(role.getCode()).willReturn("USER_ADMIN");
UserRoleBinding binding = new UserRoleBinding("usr_1", role);
- given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true);
given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of(binding));
@@ -116,7 +117,7 @@ class LocalAuthServiceTest {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
- given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);
@@ -135,7 +136,7 @@ class LocalAuthServiceTest {
credential.setFailedAttempts(4);
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
- given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);
@@ -153,7 +154,7 @@ class LocalAuthServiceTest {
credential.setLockedUntil(Instant.now(CLOCK).plusSeconds(5 * 60));
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
- given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
@@ -163,7 +164,7 @@ class LocalAuthServiceTest {
@Test
void login_withUnknownUsername_stillPerformsDummyPasswordCheck() {
- given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty());
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("ghost")).willReturn(Optional.empty());
given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu")))
.willReturn(false);
@@ -182,7 +183,7 @@ class LocalAuthServiceTest {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.DISABLED);
- given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
@@ -196,7 +197,7 @@ class LocalAuthServiceTest {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.PENDING);
- given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
@@ -210,7 +211,7 @@ class LocalAuthServiceTest {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.MERGED);
- given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
@@ -225,7 +226,7 @@ class LocalAuthServiceTest {
UserAccount user = UserAccount.systemAccount(
"system_1", "system", null, null);
- given(credentialRepository.findByUsernameIgnoreCase("system"))
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("system"))
.willReturn(Optional.of(credential));
given(userAccountRepository.findById("system_1"))
.willReturn(Optional.of(user));
@@ -242,7 +243,7 @@ class LocalAuthServiceTest {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
- given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
+ given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true);
given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of());
@@ -252,6 +253,77 @@ class LocalAuthServiceTest {
assertThat(principal.platformRoles()).containsExactly("USER");
}
+ @Test
+ void reauthenticate_withCurrentUsersPassword_returnsPrincipal() {
+ LocalCredential credential =
+ new LocalCredential("usr_1", "alice", "encoded");
+ UserAccount user =
+ new UserAccount(
+ "usr_1",
+ "alice",
+ "alice@example.com",
+ null);
+ given(credentialRepository.findByUserIdForUpdate("usr_1"))
+ .willReturn(Optional.of(credential));
+ given(userAccountRepository.findById("usr_1"))
+ .willReturn(Optional.of(user));
+ given(passwordEncoder.matches("Abcd123!", "encoded"))
+ .willReturn(true);
+ given(userRoleBindingRepository.findByUserId("usr_1"))
+ .willReturn(List.of());
+
+ PlatformPrincipal principal = service.reauthenticate(
+ "usr_1",
+ "Abcd123!");
+
+ assertThat(principal.userId()).isEqualTo("usr_1");
+ assertThat(principal.oauthProvider()).isEqualTo("local");
+ verify(credentialRepository).save(credential);
+ }
+
+ @Test
+ void reauthenticate_withInvalidPassword_updatesLockCounters() {
+ LocalCredential credential =
+ new LocalCredential("usr_1", "alice", "encoded");
+ credential.setFailedAttempts(4);
+ UserAccount user =
+ new UserAccount(
+ "usr_1",
+ "alice",
+ "alice@example.com",
+ null);
+ given(credentialRepository.findByUserIdForUpdate("usr_1"))
+ .willReturn(Optional.of(credential));
+ given(userAccountRepository.findById("usr_1"))
+ .willReturn(Optional.of(user));
+ given(passwordEncoder.matches("bad", "encoded"))
+ .willReturn(false);
+
+ assertThatThrownBy(() ->
+ service.reauthenticate("usr_1", "bad"))
+ .isInstanceOf(AuthFlowException.class)
+ .extracting("status")
+ .isEqualTo(HttpStatus.UNAUTHORIZED);
+
+ assertThat(credential.getFailedAttempts()).isEqualTo(5);
+ assertThat(credential.getLockedUntil())
+ .isEqualTo(Instant.now(CLOCK).plusSeconds(15 * 60));
+ verify(credentialRepository).save(credential);
+ }
+
+ @Test
+ void reauthenticate_withoutLocalCredential_doesNotCheckPassword() {
+ given(credentialRepository.findByUserIdForUpdate("oauth-only"))
+ .willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ service.reauthenticate("oauth-only", "secret"))
+ .isInstanceOf(AuthFlowException.class)
+ .hasMessageContaining("error.auth.local.notEnabled");
+
+ verify(passwordEncoder, never()).matches(any(), any());
+ }
+
@Test
void changePassword_withoutLocalCredential_rejectsRequest() {
given(credentialRepository.findByUserId("oauth-only")).willReturn(Optional.empty());
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java
index 0cf7ae02..054fd5ae 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java
@@ -11,6 +11,7 @@ import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService;
+import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -25,6 +26,7 @@ class IdentityProviderRouteReadinessFilterTest {
private ClientRegistrationRepository registrationRepository;
private IdentityProviderReadinessService readinessService;
private ClientRegistration registration;
+ private OAuth2LoginFailureHandler failureHandler;
private IdentityProviderRouteReadinessFilter filter;
@BeforeEach
@@ -33,12 +35,15 @@ class IdentityProviderRouteReadinessFilterTest {
ClientRegistrationRepository.class);
readinessService = mock(
IdentityProviderReadinessService.class);
+ failureHandler = mock(
+ OAuth2LoginFailureHandler.class);
registration = registration();
when(registrationRepository.findByRegistrationId("github"))
.thenReturn(registration);
filter = new IdentityProviderRouteReadinessFilter(
registrationRepository,
- readinessService);
+ readinessService,
+ failureHandler);
}
@Test
@@ -75,6 +80,32 @@ class IdentityProviderRouteReadinessFilterTest {
verify(chain, never()).doFilter(request, response);
}
+ @Test
+ void mismatchCallbackRedirectsOwnedIdentityLinkFlow()
+ throws Exception {
+ FilterChain chain = mock(FilterChain.class);
+ doThrow(new IdentityCoreException(
+ IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH))
+ .when(readinessService).requireReady(registration);
+ MockHttpServletRequest request = request(
+ "/login/oauth2/code/github");
+ MockHttpServletResponse response =
+ new MockHttpServletResponse();
+ when(failureHandler.redirectIdentityLinkRouteFailure(
+ request,
+ response,
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE))
+ .thenReturn(true);
+
+ filter.doFilter(request, response, chain);
+
+ verify(failureHandler).redirectIdentityLinkRouteFailure(
+ request,
+ response,
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
+ verify(chain, never()).doFilter(request, response);
+ }
+
@Test
void disabledAuthorizationRouteIsRejectedBeforeRedirect()
throws Exception {
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java
index 6cd0b316..160d9550 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java
@@ -1,13 +1,25 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
+import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
+import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import com.iflytek.skillhub.auth.session.PlatformSessionService;
import jakarta.servlet.http.HttpSession;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@@ -15,6 +27,7 @@ import static org.mockito.Mockito.mock;
class OAuth2AuthorizationRequestResolverTest {
private SkillHubOAuth2AuthorizationRequestResolver resolver;
+ private OAuthLoginFlowService oauthLoginFlowService;
@BeforeEach
void setUp() {
@@ -30,17 +43,64 @@ class OAuth2AuthorizationRequestResolverTest {
.scope("read:user")
.clientName("GitHub")
.build();
- OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService(
+ oauthLoginFlowService = new OAuthLoginFlowService(
java.util.List.of(),
mock(TrustedProviderRouteResolver.class),
- mock(ExternalIdentityLoginService.class)
+ mock(ExternalIdentityLoginService.class),
+ mock(ExternalIdentityLinkService.class),
+ mock(IdentityLinkSessionManager.class)
);
resolver = new SkillHubOAuth2AuthorizationRequestResolver(
new InMemoryClientRegistrationRepository(github),
- oauthLoginFlowService
+ oauthLoginFlowService,
+ mock(IdentityLinkSessionManager.class)
);
}
+ @Test
+ void resolve_preservesReturnToAcrossCallbackUntilSuccessHandler()
+ throws Exception {
+ String returnTo =
+ "/settings/security?identityLink=linked"
+ + "&intentId=7d26c414-6040-48b5-b025-53a16b8aa6b9";
+ MockHttpServletRequest authorizationRequest =
+ oauthRequest("/oauth2/authorization/github");
+ authorizationRequest.setParameter("returnTo", returnTo);
+
+ assertThat(resolver.resolve(authorizationRequest)).isNotNull();
+
+ MockHttpSession session =
+ (MockHttpSession) authorizationRequest.getSession(false);
+ assertThat(session).isNotNull();
+ assertThat(session.getAttribute(
+ OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE))
+ .isEqualTo(returnTo);
+
+ MockHttpServletRequest callbackRequest =
+ oauthRequest("/login/oauth2/code/github");
+ callbackRequest.setSession(session);
+ assertThat(resolver.resolve(callbackRequest)).isNull();
+ assertThat(session.getAttribute(
+ OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE))
+ .isEqualTo(returnTo);
+
+ OAuth2LoginSuccessHandler successHandler =
+ new OAuth2LoginSuccessHandler(
+ new PlatformSessionService(),
+ oauthLoginFlowService);
+ MockHttpServletResponse response =
+ new MockHttpServletResponse();
+ successHandler.onAuthenticationSuccess(
+ callbackRequest,
+ response,
+ oauthAuthentication());
+
+ assertThat(response.getRedirectedUrl()).isEqualTo(returnTo);
+ assertThat(session.getAttribute(
+ OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE))
+ .isNull();
+ }
+
@Test
void resolve_storesSanitizedReturnToInSession() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github");
@@ -65,4 +125,41 @@ class OAuth2AuthorizationRequestResolverTest {
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
+
+ @Test
+ void resolve_nonAuthorizationRequestDoesNotCreateSession() {
+ MockHttpServletRequest request =
+ oauthRequest("/login/oauth2/code/github");
+
+ assertThat(resolver.resolve(request)).isNull();
+ assertThat(request.getSession(false)).isNull();
+ }
+
+ private MockHttpServletRequest oauthRequest(String path) {
+ MockHttpServletRequest request =
+ new MockHttpServletRequest("GET", path);
+ request.setServletPath(path);
+ return request;
+ }
+
+ private Authentication oauthAuthentication() {
+ PlatformPrincipal principal = new PlatformPrincipal(
+ "user-1",
+ "User",
+ "user@example.com",
+ null,
+ "github",
+ Set.of());
+ return new UsernamePasswordAuthenticationToken(
+ new DefaultOAuth2User(
+ List.of(),
+ Map.of(
+ "platformPrincipal",
+ principal,
+ "login",
+ "user"),
+ "login"),
+ null,
+ List.of());
+ }
}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java
index 52c0077b..639bea0f 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java
@@ -1,5 +1,7 @@
package com.iflytek.skillhub.auth.oauth;
+import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
+import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import jakarta.servlet.http.HttpSession;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
@@ -15,7 +17,9 @@ import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
+import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@@ -104,7 +108,9 @@ class OAuth2LoginHandlersTest {
@Test
void failureHandler_redirectsBackToLoginWithReturnTo() throws Exception {
OAuthLoginFlowService oauthLoginFlowService = mock(OAuthLoginFlowService.class);
- OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(oauthLoginFlowService);
+ OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(
+ oauthLoginFlowService,
+ mock(IdentityLinkSessionManager.class));
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HttpSession session = request.getSession(true);
@@ -130,4 +136,129 @@ class OAuth2LoginHandlersTest {
assertThat(response.getRedirectedUrl()).isEqualTo("/login?returnTo=%2Fsettings%2Faccounts");
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
+
+ @Test
+ void failureHandler_preservesIdentityLinkIntentForRetry()
+ throws Exception {
+ OAuthLoginFlowService oauthLoginFlowService =
+ mock(OAuthLoginFlowService.class);
+ IdentityLinkSessionManager sessionManager =
+ mock(IdentityLinkSessionManager.class);
+ OAuth2LoginFailureHandler handler =
+ new OAuth2LoginFailureHandler(
+ oauthLoginFlowService,
+ sessionManager);
+ MockHttpServletRequest request =
+ new MockHttpServletRequest();
+ MockHttpServletResponse response =
+ new MockHttpServletResponse();
+ HttpSession session = request.getSession(true);
+ UUID intentId = UUID.randomUUID();
+ org.mockito.Mockito.when(
+ sessionManager.consumeFailedBrowserFlow(session))
+ .thenReturn(Optional.of(intentId));
+
+ handler.onAuthenticationFailure(
+ request,
+ response,
+ new OAuth2AuthenticationException(
+ new OAuth2Error("access_denied")));
+
+ assertThat(response.getRedirectedUrl())
+ .isEqualTo(
+ "/settings/security?identityLink=failed"
+ + "&intentId="
+ + intentId
+ + "&reasonCode="
+ + "PROVIDER_AUTHENTICATION_FAILED");
+ org.mockito.Mockito.verify(
+ oauthLoginFlowService,
+ org.mockito.Mockito.never())
+ .resolveFailureRedirect(
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void failureHandler_preservesStableIdentityLinkReasonCode()
+ throws Exception {
+ OAuthLoginFlowService oauthLoginFlowService =
+ mock(OAuthLoginFlowService.class);
+ IdentityLinkSessionManager sessionManager =
+ mock(IdentityLinkSessionManager.class);
+ OAuth2LoginFailureHandler handler =
+ new OAuth2LoginFailureHandler(
+ oauthLoginFlowService,
+ sessionManager);
+ MockHttpServletRequest request =
+ new MockHttpServletRequest();
+ MockHttpServletResponse response =
+ new MockHttpServletResponse();
+ HttpSession session = request.getSession(true);
+ UUID intentId = UUID.randomUUID();
+ OAuth2AuthenticationException failure =
+ new OAuth2AuthenticationException(
+ new OAuth2Error(
+ "identity_link_failed",
+ "PROVIDER_UNAVAILABLE",
+ null));
+ org.mockito.Mockito.when(
+ sessionManager.consumeFailedBrowserFlow(session))
+ .thenReturn(Optional.of(intentId));
+ org.mockito.Mockito.when(
+ oauthLoginFlowService
+ .identityLinkFailureReasonCode(failure))
+ .thenReturn(Optional.of(
+ "PROVIDER_UNAVAILABLE"));
+
+ handler.onAuthenticationFailure(
+ request,
+ response,
+ failure);
+
+ assertThat(response.getRedirectedUrl())
+ .isEqualTo(
+ "/settings/security?identityLink=failed"
+ + "&intentId="
+ + intentId
+ + "&reasonCode=PROVIDER_UNAVAILABLE");
+ }
+
+ @Test
+ void routeFailureRedirectsOnlyAnOwnedIdentityLinkFlow()
+ throws Exception {
+ OAuthLoginFlowService oauthLoginFlowService =
+ mock(OAuthLoginFlowService.class);
+ IdentityLinkSessionManager sessionManager =
+ mock(IdentityLinkSessionManager.class);
+ OAuth2LoginFailureHandler handler =
+ new OAuth2LoginFailureHandler(
+ oauthLoginFlowService,
+ sessionManager);
+ MockHttpServletRequest request =
+ new MockHttpServletRequest();
+ MockHttpServletResponse response =
+ new MockHttpServletResponse();
+ HttpSession session = request.getSession(true);
+ UUID intentId = UUID.randomUUID();
+ org.mockito.Mockito.when(
+ sessionManager.consumeFailedBrowserFlow(session))
+ .thenReturn(Optional.of(intentId));
+
+ boolean redirected =
+ handler.redirectIdentityLinkRouteFailure(
+ request,
+ response,
+ IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
+
+ assertThat(redirected).isTrue();
+ assertThat(response.getRedirectedUrl())
+ .isEqualTo(
+ "/settings/security?identityLink=failed"
+ + "&intentId="
+ + intentId
+ + "&reasonCode=PROVIDER_UNAVAILABLE");
+ org.mockito.Mockito.verify(oauthLoginFlowService)
+ .consumeReturnTo(session);
+ }
}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java
index e65a5206..948b58e6 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java
@@ -13,10 +13,16 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
+import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
+import com.iflytek.skillhub.auth.identity.IdentityLinkActor;
+import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow;
+import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase;
+import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome;
+import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
@@ -29,6 +35,9 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.Optional;
+import java.util.UUID;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.mock.web.MockHttpServletRequest;
@@ -39,9 +48,16 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
class OAuthLoginFlowServiceTest {
+ @AfterEach
+ void resetRequestContext() {
+ RequestContextHolder.resetRequestAttributes();
+ }
+
@Test
void resolvesReadyRouteBeforeOAuthUpstreamAndAdapterCalls() {
OAuthClaimsExtractor extractor = mock(OAuthClaimsExtractor.class);
@@ -74,6 +90,8 @@ class OAuthLoginFlowServiceTest {
List.of(extractor),
resolver,
identityLoginService,
+ mock(ExternalIdentityLinkService.class),
+ mock(IdentityLinkSessionManager.class),
delegate);
clearInvocations(extractor);
@@ -114,6 +132,8 @@ class OAuthLoginFlowServiceTest {
List.of(extractor),
resolver,
identityLoginService,
+ mock(ExternalIdentityLinkService.class),
+ mock(IdentityLinkSessionManager.class),
delegate);
clearInvocations(extractor);
@@ -139,7 +159,9 @@ class OAuthLoginFlowServiceTest {
new OAuthLoginFlowService(
List.of(),
resolver,
- identityLoginService);
+ identityLoginService,
+ mock(ExternalIdentityLinkService.class),
+ mock(IdentityLinkSessionManager.class));
PlatformPrincipal principal = principal();
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.Authenticated(
@@ -170,7 +192,9 @@ class OAuthLoginFlowServiceTest {
new OAuthLoginFlowService(
List.of(),
resolver,
- identityLoginService);
+ identityLoginService,
+ mock(ExternalIdentityLinkService.class),
+ mock(IdentityLinkSessionManager.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.PendingApproval(
"ACCOUNT_PENDING"));
@@ -193,7 +217,9 @@ class OAuthLoginFlowServiceTest {
new OAuthLoginFlowService(
List.of(),
resolver,
- identityLoginService);
+ identityLoginService,
+ mock(ExternalIdentityLinkService.class),
+ mock(IdentityLinkSessionManager.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.LinkRequired(
"EMAIL_COLLISION"));
@@ -228,7 +254,9 @@ class OAuthLoginFlowServiceTest {
new OAuthLoginFlowService(
List.of(),
resolver,
- identityLoginService);
+ identityLoginService,
+ mock(ExternalIdentityLinkService.class),
+ mock(IdentityLinkSessionManager.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenThrow(new IdentityCoreException(
IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH));
@@ -246,6 +274,68 @@ class OAuthLoginFlowServiceTest {
"provider_authority_mismatch"));
}
+ @Test
+ void browserLinkFlowDoesNotRunNormalLoginOrReplacePrimaryAccount() {
+ TrustedProviderRouteResolver resolver =
+ mock(TrustedProviderRouteResolver.class);
+ ExternalIdentityLoginService identityLoginService =
+ mock(ExternalIdentityLoginService.class);
+ ExternalIdentityLinkService identityLinkService =
+ mock(ExternalIdentityLinkService.class);
+ IdentityLinkSessionManager sessionManager =
+ mock(IdentityLinkSessionManager.class);
+ OAuthLoginFlowService service =
+ new OAuthLoginFlowService(
+ List.of(),
+ resolver,
+ identityLoginService,
+ identityLinkService,
+ sessionManager);
+ ResolvedProviderHandle provider =
+ ResolvedProviderHandleTestFixture.handle("github");
+ UUID intentId = UUID.randomUUID();
+ IdentityLinkActor actor = new IdentityLinkActor(
+ "usr_1",
+ "local",
+ "high-entropy-session-nonce",
+ context());
+ MockHttpServletRequest request =
+ new MockHttpServletRequest(
+ "GET",
+ "/login/oauth2/code/github");
+ request.getSession(true);
+ RequestContextHolder.setRequestAttributes(
+ new ServletRequestAttributes(request));
+ when(sessionManager.consumeBrowserFlow(
+ request,
+ "github",
+ context()))
+ .thenReturn(Optional.of(
+ new IdentityLinkBrowserFlow(
+ intentId,
+ IdentityLinkBrowserPhase.LINK,
+ actor)));
+ when(identityLinkService.link(
+ actor,
+ intentId,
+ provider,
+ result()))
+ .thenReturn(new IdentityLinkOutcome.Linked(
+ principal(),
+ 42L));
+
+ PlatformPrincipal linked = service.authenticate(
+ provider,
+ result(),
+ context());
+
+ assertThat(linked.userId()).isEqualTo("usr_1");
+ verifyNoInteractions(identityLoginService);
+ verify(sessionManager).remove(
+ request.getSession(false),
+ intentId);
+ }
+
@Test
void rememberReturnToStoresSanitizedReturnTarget() {
OAuthLoginFlowService service = service();
@@ -273,6 +363,46 @@ class OAuthLoginFlowServiceTest {
assertThat(redirect).isEqualTo("/access-denied");
}
+ @Test
+ void identityLinkFailureRedirectPreservesResumableIntent() {
+ UUID intentId = UUID.randomUUID();
+
+ String redirect = service().resolveFailureRedirect(
+ new OAuth2AuthenticationException(
+ new OAuth2Error("identity_link_failed")),
+ "/settings/security?identityLink=linked"
+ + "&intentId="
+ + intentId);
+
+ assertThat(redirect)
+ .isEqualTo(
+ "/settings/security?identityLink=failed"
+ + "&intentId="
+ + intentId);
+ }
+
+ @Test
+ void identityLinkFailureRedirectIncludesStableReasonCode() {
+ UUID intentId = UUID.randomUUID();
+
+ String redirect = service().resolveFailureRedirect(
+ new OAuth2AuthenticationException(
+ new OAuth2Error(
+ "identity_link_failed",
+ "PROVIDER_UNAVAILABLE",
+ null)),
+ "/settings/security?identityLink=linked"
+ + "&intentId="
+ + intentId);
+
+ assertThat(redirect)
+ .isEqualTo(
+ "/settings/security?identityLink=failed"
+ + "&intentId="
+ + intentId
+ + "&reasonCode=PROVIDER_UNAVAILABLE");
+ }
+
@Test
void resolveFailureRedirectMapsMergedAccountToAccessDenied() {
assertThat(service().resolveFailureRedirect(
@@ -308,7 +438,9 @@ class OAuthLoginFlowServiceTest {
return new OAuthLoginFlowService(
List.of(),
mock(TrustedProviderRouteResolver.class),
- mock(ExternalIdentityLoginService.class));
+ mock(ExternalIdentityLoginService.class),
+ mock(ExternalIdentityLinkService.class),
+ mock(IdentityLinkSessionManager.class));
}
private static ProviderAuthenticationResult result() {
diff --git a/web/e2e/settings-security-capability.spec.ts b/web/e2e/settings-security-capability.spec.ts
index b2d45910..e579b6b2 100644
--- a/web/e2e/settings-security-capability.spec.ts
+++ b/web/e2e/settings-security-capability.spec.ts
@@ -1,7 +1,7 @@
import { expect, test, type Page } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { csrfHeaders } from './helpers/csrf'
-import { loginWithCredentials } from './helpers/session'
+import { createFreshSession, loginWithCredentials } from './helpers/session'
function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim()
@@ -15,6 +15,20 @@ function adminCredentials() {
}
}
+function gitLabIdentityLinkE2EEnabled(): boolean {
+ return getOptionalEnv('E2E_IDENTITY_LINK_BROWSER_PROVIDER') === 'gitlab'
+}
+
+function requireGitLabIdentityLinkE2E(): void {
+ const enabled = gitLabIdentityLinkE2EEnabled()
+ if (!enabled && process.env.CI) {
+ throw new Error(
+ 'E2E_IDENTITY_LINK_BROWSER_PROVIDER=gitlab is required in CI',
+ )
+ }
+ test.skip(!enabled, 'requires the dedicated GitLab identity-link test provider')
+}
+
async function currentDisplayName(page: Page, headers?: Record): Promise {
const response = await page.context().request.get('/api/v1/auth/me', { headers })
expect(response.ok()).toBeTruthy()
@@ -23,7 +37,9 @@ async function currentDisplayName(page: Page, headers?: Record):
}
test.describe('Security Settings capability (Real API)', () => {
- test.use({ baseURL: 'http://127.0.0.1:3000' })
+ test.use({
+ baseURL: getOptionalEnv('E2E_BASE_URL') ?? 'http://127.0.0.1:3000',
+ })
test('shows the security menu entry and password form for local admin accounts', async ({ page }, testInfo) => {
await setEnglishLocale(page)
@@ -32,6 +48,8 @@ test.describe('Security Settings capability (Real API)', () => {
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Login Methods', exact: true })).toBeVisible()
+ await expect(page.getByText('Local password', { exact: true }).first()).toBeVisible()
await expect(page.getByLabel('Current Password')).toBeVisible()
await expect(page.getByLabel('New Password')).toBeVisible()
@@ -39,6 +57,125 @@ test.describe('Security Settings capability (Real API)', () => {
await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible()
})
+ test('requires fresh local reauthentication before linking an external provider', async ({ page }, testInfo) => {
+ requireGitLabIdentityLinkE2E()
+ await setEnglishLocale(page)
+ const credentials = await createFreshSession(page, testInfo)
+
+ await page.goto('/settings/security')
+ await expect(page.getByRole('heading', { name: 'Login Methods', exact: true })).toBeVisible()
+ const addButton = page.getByRole('button', { name: 'Add' }).first()
+ await expect(addButton).toBeVisible()
+ await addButton.click()
+
+ const dialog = page.getByRole('dialog', { name: 'Verify account control' })
+ await expect(dialog).toBeVisible()
+ await dialog.getByLabel('Local password').fill(credentials.password)
+ await dialog.getByRole('button', { name: 'Verify password' }).click()
+
+ await expect(dialog.getByText(
+ 'Current account verified. Now authenticate the login method you want to link.',
+ )).toBeVisible()
+ await expect(dialog.getByRole('button', { name: /^Continue with / })).toBeVisible()
+
+ await dialog.getByRole('button', { name: 'Cancel' }).click()
+ await expect(dialog).toBeHidden()
+ })
+
+ test('links, unlinks, and relinks a browser identity through the deployed stack', async ({ page }, testInfo) => {
+ requireGitLabIdentityLinkE2E()
+ await setEnglishLocale(page)
+ const credentials = await createFreshSession(page, testInfo)
+ await page.goto('/settings/security')
+
+ const availableMethods = page.locator(
+ 'section[aria-labelledby="available-login-methods"]',
+ )
+ const linkedMethods = page.locator(
+ 'section[aria-labelledby="linked-login-methods"]',
+ )
+
+ async function linkGitLab() {
+ await expect(availableMethods.getByText('GitLab', { exact: true })).toBeVisible()
+ await availableMethods.getByRole('button', { name: 'Add' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Verify account control' })
+ await dialog.getByLabel('Local password').fill(credentials.password)
+ await dialog.getByRole('button', { name: 'Verify password' }).click()
+ await expect(dialog.getByRole('button', { name: 'Continue with GitLab' })).toBeVisible()
+ await dialog.getByRole('button', { name: 'Continue with GitLab' }).click()
+ await page.waitForURL(/identityLink=linked/)
+ await expect(page.getByText('The login method was linked successfully.')).toBeVisible()
+ await expect(linkedMethods.getByText('GitLab', { exact: true })).toBeVisible()
+ }
+
+ await linkGitLab()
+
+ await linkedMethods.getByRole('button', { name: 'Remove' }).click()
+ const unlinkDialog = page.getByRole('dialog', { name: 'Verify account control' })
+ await unlinkDialog.getByLabel('Local password').fill(credentials.password)
+ await unlinkDialog.getByRole('button', { name: 'Verify password' }).click()
+ await expect(
+ unlinkDialog.getByRole('button', { name: 'Remove login method' }),
+ ).toBeVisible()
+ await unlinkDialog.getByRole('button', { name: 'Remove login method' }).click()
+ await expect(unlinkDialog).toBeHidden()
+ await expect(availableMethods.getByText('GitLab', { exact: true })).toBeVisible()
+
+ await linkGitLab()
+ })
+
+ test('redirects an unavailable identity-link provider with a stable reason code', async ({ page }, testInfo) => {
+ requireGitLabIdentityLinkE2E()
+ await setEnglishLocale(page)
+ const credentials = await createFreshSession(page, testInfo)
+ const request = page.context().request
+
+ const createIntent = await request.post('/api/v1/auth/identity-link-intents/link', {
+ data: { providerCode: 'gitlab' },
+ headers: await csrfHeaders(page),
+ })
+ expect(createIntent.ok()).toBeTruthy()
+ const createBody = await createIntent.json() as { data: { id: string } }
+ const intentId = createBody.data.id
+
+ const reauthenticate = await request.post(
+ `/api/v1/auth/identity-link-intents/${intentId}/reauthenticate/local`,
+ {
+ data: { password: credentials.password },
+ headers: await csrfHeaders(page),
+ },
+ )
+ expect(reauthenticate.ok()).toBeTruthy()
+
+ const prepareLink = await request.post(
+ `/api/v1/auth/identity-link-intents/${intentId}/link/browser`,
+ { headers: await csrfHeaders(page) },
+ )
+ expect(prepareLink.ok()).toBeTruthy()
+ const prepareBody = await prepareLink.json() as { data: { actionUrl: string } }
+ const unavailableActionUrl = prepareBody.data.actionUrl.replace(
+ '/oauth2/authorization/gitlab',
+ '/oauth2/authorization/missing-provider',
+ )
+ expect(unavailableActionUrl).not.toBe(prepareBody.data.actionUrl)
+
+ const failure = await request.get(unavailableActionUrl, { maxRedirects: 0 })
+ expect(failure.status()).toBe(302)
+ const location = failure.headers().location
+ expect(location).toBeTruthy()
+ const redirect = new URL(location, 'http://127.0.0.1')
+ expect(redirect.pathname).toBe('/settings/security')
+ expect(redirect.searchParams.get('identityLink')).toBe('failed')
+ expect(redirect.searchParams.get('intentId')).toBe(intentId)
+ expect(redirect.searchParams.get('reasonCode')).toBe('PROVIDER_UNAVAILABLE')
+
+ const ordinaryOAuth = await request.get(
+ '/oauth2/authorization/missing-provider',
+ { maxRedirects: 0 },
+ )
+ expect(ordinaryOAuth.status()).toBe(403)
+ })
+
test('hides the security menu entry and rejects password changes without a local credential', async ({ page }) => {
await setEnglishLocale(page)
await page.context().setExtraHTTPHeaders({
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
index 36a60ebf..71299124 100644
--- a/web/playwright.config.ts
+++ b/web/playwright.config.ts
@@ -29,10 +29,12 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
- webServer: {
- command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort',
- url: 'http://127.0.0.1:3000',
- reuseExistingServer: true,
- timeout: 120000,
- },
+ webServer: process.env.E2E_BASE_URL
+ ? undefined
+ : {
+ command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort',
+ url: 'http://127.0.0.1:3000',
+ reuseExistingServer: true,
+ timeout: 120000,
+ },
})
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts
index f2d4287b..5b7fd3eb 100644
--- a/web/src/api/client.test.ts
+++ b/web/src/api/client.test.ts
@@ -24,11 +24,19 @@ vi.mock('@/shared/lib/api-error', () => ({
status: number
serverMessage?: string
serverMessageKey?: string
- constructor(message: string, status: number, serverMessage?: string, serverMessageKey?: string) {
+ reasonCode?: string
+ constructor(
+ message: string,
+ status: number,
+ serverMessage?: string,
+ serverMessageKey?: string,
+ reasonCode?: string,
+ ) {
super(message)
this.status = status
this.serverMessage = serverMessage
this.serverMessageKey = serverMessageKey
+ this.reasonCode = reasonCode
}
},
handleApiError: vi.fn(),
@@ -40,6 +48,7 @@ import {
fetchText,
getDirectAuthRuntimeConfig,
getSessionBootstrapRuntimeConfig,
+ identityLinkApi,
namespaceApi,
} from './client'
@@ -163,6 +172,131 @@ describe('namespaceApi.delete', () => {
})
})
+describe('identityLinkApi', () => {
+ it('normalizes the login-method account state', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({
+ code: 0,
+ msg: 'ok',
+ data: {
+ localPasswordEnabled: true,
+ linkedProviders: [{
+ bindingId: 41,
+ providerCode: 'github',
+ displayName: 'GitHub',
+ methodTypes: ['OAUTH_REDIRECT'],
+ usable: true,
+ canUnlink: true,
+ }],
+ availableProviders: [{
+ providerCode: 'oidc',
+ displayName: 'Company OIDC',
+ methodTypes: ['OAUTH_REDIRECT'],
+ }],
+ },
+ timestamp: '2026-07-31T00:00:00Z',
+ requestId: 'req-identity-link',
+ }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ await expect(identityLinkApi.getAccountState()).resolves.toEqual({
+ localPasswordEnabled: true,
+ linkedProviders: [{
+ bindingId: 41,
+ providerCode: 'github',
+ displayName: 'GitHub',
+ methodTypes: ['OAUTH_REDIRECT'],
+ usable: true,
+ canUnlink: true,
+ }],
+ availableProviders: [{
+ providerCode: 'oidc',
+ displayName: 'Company OIDC',
+ methodTypes: ['OAUTH_REDIRECT'],
+ }],
+ })
+ })
+
+ it('creates a session-bound link intent with CSRF protection', async () => {
+ Object.defineProperty(globalThis, 'document', {
+ configurable: true,
+ writable: true,
+ value: {
+ cookie: 'XSRF-TOKEN=identity-link-csrf',
+ },
+ })
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({
+ code: 0,
+ msg: 'ok',
+ data: {
+ id: 'a0b89f51-a892-4b73-bdac-63df2cb14691',
+ operation: 'LINK',
+ status: 'PENDING_REAUTHENTICATION',
+ providerCode: 'github',
+ expiresAt: '2026-07-31T00:10:00Z',
+ },
+ timestamp: '2026-07-31T00:00:00Z',
+ requestId: 'req-identity-link',
+ }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ const intent = await identityLinkApi.createLinkIntent('github')
+
+ expect(intent.status).toBe('PENDING_REAUTHENTICATION')
+ const request = fetchMock.mock.calls[0]?.[0] as Request
+ expect(request.url).toBe(
+ 'http://localhost/api/v1/auth/identity-link-intents/link',
+ )
+ expect(request.method).toBe('POST')
+ await expect(request.clone().json()).resolves.toEqual({
+ providerCode: 'github',
+ })
+ expect(request.headers.get('X-XSRF-TOKEN'))
+ .toBe('identity-link-csrf')
+ })
+
+ it('preserves stable identity-link failure reason codes', async () => {
+ Object.defineProperty(globalThis, 'document', {
+ configurable: true,
+ writable: true,
+ value: {
+ cookie: 'XSRF-TOKEN=identity-link-csrf',
+ },
+ })
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({
+ code: 409,
+ msg: 'Keep another login method.',
+ reasonCode: 'FINAL_LOGIN_METHOD',
+ timestamp: '2026-07-31T00:00:00Z',
+ requestId: 'req-identity-link-error',
+ }), {
+ status: 409,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ await expect(
+ identityLinkApi.completeUnlink(
+ 'a0b89f51-a892-4b73-bdac-63df2cb14691',
+ ),
+ ).rejects.toMatchObject({
+ status: 409,
+ reasonCode: 'FINAL_LOGIN_METHOD',
+ })
+ })
+})
+
describe('getDirectAuthRuntimeConfig', () => {
it('returns disabled when no runtime config is present', () => {
const config = getDirectAuthRuntimeConfig()
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index d701fd11..3e843ab8 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -1,5 +1,5 @@
import createClient from 'openapi-fetch'
-import type { paths } from './generated/schema'
+import type { components, paths } from './generated/schema'
import type {
ChangePasswordRequest,
PasswordResetConfirmRequest,
@@ -29,6 +29,12 @@ import type {
PagedResponse,
ReportDisposition,
AuthMethod,
+ IdentityLinkAccountState,
+ IdentityLinkBinding,
+ IdentityLinkCredentialRequest,
+ IdentityLinkIntent,
+ IdentityLinkProvider,
+ IdentityProviderLoginMethodType,
OAuthProvider,
User,
ManagedNamespace,
@@ -85,6 +91,22 @@ function getApiBaseUrl(): string {
return getRuntimeConfig().apiBaseUrl ?? ''
}
+function getOpenApiBaseUrl(): string {
+ const configured = getApiBaseUrl()
+ if (/^https?:\/\//i.test(configured)) {
+ return configured
+ }
+ if (
+ typeof window !== 'undefined'
+ && typeof window.location?.origin === 'string'
+ ) {
+ return configured
+ ? prependApiBaseUrl(window.location.origin, configured)
+ : window.location.origin
+ }
+ return configured || 'http://localhost'
+}
+
function parseBooleanFlag(value: string | undefined): boolean {
if (!value) {
return false
@@ -92,7 +114,10 @@ function parseBooleanFlag(value: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
}
-const client = createClient({ baseUrl: getApiBaseUrl() })
+const client = createClient({
+ baseUrl: getOpenApiBaseUrl(),
+ fetch: (request) => globalThis.fetch(request),
+})
function getCsrfToken(): string | null {
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
@@ -170,6 +195,7 @@ type ApiEnvelope = {
code: number
msg: string
data: T
+ reasonCode?: string
timestamp: string
requestId: string
}
@@ -415,6 +441,352 @@ export const authApi = {
},
}
+type IdentityLinkIntentSchema = components['schemas']['IdentityLinkIntentResponse']
+type IdentityLinkAccountStateSchema =
+ components['schemas']['IdentityLinkAccountStateResponse']
+type IdentityLinkBindingSchema = components['schemas']['IdentityLinkBindingResponse']
+type IdentityLinkProviderSchema = components['schemas']['IdentityLinkProviderResponse']
+type IdentityLinkBrowserStartSchema =
+ components['schemas']['IdentityLinkBrowserStartResponse']
+type IdentityLinkErrorSchema =
+ components['schemas']['IdentityLinkErrorResponse']
+
+function normalizeIdentityLinkMethodTypes(
+ methodTypes: IdentityLinkBindingSchema['methodTypes']
+ | IdentityLinkProviderSchema['methodTypes'],
+): IdentityProviderLoginMethodType[] {
+ return methodTypes ? [...methodTypes] : []
+}
+
+function normalizeIdentityLinkBinding(
+ binding: IdentityLinkBindingSchema,
+): IdentityLinkBinding {
+ if (
+ binding.bindingId === undefined
+ || !binding.providerCode
+ || !binding.displayName
+ || binding.usable === undefined
+ || binding.canUnlink === undefined
+ ) {
+ throw new ApiError('apiError.invalidResponse', 500)
+ }
+ return {
+ ...binding,
+ bindingId: binding.bindingId,
+ providerCode: binding.providerCode,
+ displayName: binding.displayName,
+ methodTypes: normalizeIdentityLinkMethodTypes(binding.methodTypes),
+ usable: binding.usable,
+ canUnlink: binding.canUnlink,
+ }
+}
+
+function normalizeIdentityLinkProvider(
+ provider: IdentityLinkProviderSchema,
+): IdentityLinkProvider {
+ if (!provider.providerCode || !provider.displayName) {
+ throw new ApiError('apiError.invalidResponse', 500)
+ }
+ return {
+ ...provider,
+ providerCode: provider.providerCode,
+ displayName: provider.displayName,
+ methodTypes: normalizeIdentityLinkMethodTypes(provider.methodTypes),
+ }
+}
+
+function normalizeIdentityLinkIntent(
+ intent: IdentityLinkIntentSchema,
+): IdentityLinkIntent {
+ if (
+ !intent.id
+ || !intent.operation
+ || !intent.status
+ || !intent.providerCode
+ || !intent.expiresAt
+ ) {
+ throw new ApiError('apiError.invalidResponse', 500)
+ }
+ return {
+ ...intent,
+ id: intent.id,
+ operation: intent.operation,
+ status: intent.status,
+ providerCode: intent.providerCode,
+ targetBindingId: intent.targetBindingId,
+ expiresAt: intent.expiresAt,
+ }
+}
+
+function normalizeIdentityLinkAccountState(
+ state: IdentityLinkAccountStateSchema,
+): IdentityLinkAccountState {
+ return {
+ localPasswordEnabled: state.localPasswordEnabled === true,
+ linkedProviders: (state.linkedProviders ?? []).map(
+ normalizeIdentityLinkBinding,
+ ),
+ availableProviders: (state.availableProviders ?? []).map(
+ normalizeIdentityLinkProvider,
+ ),
+ }
+}
+
+function requireIdentityLinkActionUrl(
+ response: IdentityLinkBrowserStartSchema,
+): string {
+ if (!response.actionUrl) {
+ throw new ApiError('apiError.invalidResponse', 500)
+ }
+ return response.actionUrl
+}
+
+type GeneratedApiEnvelope = {
+ code?: number
+ msg?: string
+ data?: T
+}
+
+type OpenApiEnvelopeResult = {
+ data?: GeneratedApiEnvelope
+ error?: unknown
+ response: Response
+}
+
+function isApiFailureEnvelope(
+ value: unknown,
+): value is IdentityLinkErrorSchema {
+ return typeof value === 'object'
+ && value !== null
+ && ('msg' in value || 'reasonCode' in value)
+}
+
+function unwrapOpenApiEnvelope(
+ result: OpenApiEnvelopeResult,
+): T {
+ const failure = isApiFailureEnvelope(result.error)
+ ? result.error
+ : undefined
+ const envelope = result.data
+ if (
+ !result.response.ok
+ || result.error !== undefined
+ || envelope?.code !== 0
+ || envelope?.data === undefined
+ ) {
+ const message = failure?.msg
+ || envelope?.msg
+ || `HTTP ${result.response.status}`
+ throw new ApiError(
+ message,
+ result.response.status,
+ failure?.msg,
+ failure?.msg,
+ failure?.reasonCode,
+ )
+ }
+ return envelope.data
+}
+
+export const identityLinkApi = {
+ async getAccountState(): Promise {
+ const result = await client.GET(
+ '/api/v1/auth/identity-links',
+ {
+ headers: withRequestHeaders(),
+ },
+ )
+ return normalizeIdentityLinkAccountState(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async getIntent(intentId: string): Promise {
+ const result = await client.GET(
+ '/api/v1/auth/identity-link-intents/{intentId}',
+ {
+ params: {
+ path: { intentId },
+ },
+ headers: withRequestHeaders(),
+ },
+ )
+ return normalizeIdentityLinkIntent(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async createLinkIntent(providerCode: string): Promise {
+ const result = await client.POST(
+ '/api/v1/auth/identity-link-intents/link',
+ {
+ headers: await ensureCsrfHeaders(),
+ body: { providerCode },
+ },
+ )
+ return normalizeIdentityLinkIntent(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async createUnlinkIntent(bindingId: number): Promise {
+ const result = await client.POST(
+ '/api/v1/auth/identity-link-intents/unlink',
+ {
+ headers: await ensureCsrfHeaders(),
+ body: { bindingId },
+ },
+ )
+ return normalizeIdentityLinkIntent(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async cancel(intentId: string): Promise {
+ const result = await client.DELETE(
+ '/api/v1/auth/identity-link-intents/{intentId}',
+ {
+ params: {
+ path: { intentId },
+ },
+ headers: await ensureCsrfHeaders(),
+ },
+ )
+ return normalizeIdentityLinkIntent(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async reauthenticateLocal(
+ intentId: string,
+ password: string,
+ ): Promise {
+ const result = await client.POST(
+ '/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/local',
+ {
+ params: {
+ path: { intentId },
+ },
+ headers: await ensureCsrfHeaders(),
+ body: { password },
+ },
+ )
+ return normalizeIdentityLinkIntent(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async prepareBrowserReauthentication(
+ intentId: string,
+ providerCode: string,
+ ): Promise {
+ const result = await client.POST(
+ '/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/browser',
+ {
+ params: {
+ path: { intentId },
+ },
+ headers: await ensureCsrfHeaders(),
+ body: { providerCode },
+ },
+ )
+ return requireIdentityLinkActionUrl(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async reauthenticateCredential(
+ intentId: string,
+ providerCode: string,
+ credentials: IdentityLinkCredentialRequest,
+ ): Promise {
+ const result = await client.POST(
+ '/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/credential',
+ {
+ params: {
+ path: { intentId },
+ },
+ headers: await ensureCsrfHeaders(),
+ body: { providerCode, ...credentials },
+ },
+ )
+ return normalizeIdentityLinkIntent(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async prepareBrowserLink(intentId: string): Promise {
+ const result = await client.POST(
+ '/api/v1/auth/identity-link-intents/{intentId}/link/browser',
+ {
+ params: {
+ path: { intentId },
+ },
+ headers: await ensureCsrfHeaders(),
+ },
+ )
+ return requireIdentityLinkActionUrl(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async linkCredential(
+ intentId: string,
+ credentials: IdentityLinkCredentialRequest,
+ ): Promise {
+ const result = await client.POST(
+ '/api/v1/auth/identity-link-intents/{intentId}/link/credential',
+ {
+ params: {
+ path: { intentId },
+ },
+ headers: await ensureCsrfHeaders(),
+ body: credentials,
+ },
+ )
+ return normalizeIdentityLinkIntent(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+
+ async completeUnlink(intentId: string): Promise {
+ const result = await client.POST(
+ '/api/v1/auth/identity-link-intents/{intentId}/unlink',
+ {
+ params: {
+ path: { intentId },
+ },
+ headers: await ensureCsrfHeaders(),
+ },
+ )
+ return normalizeIdentityLinkIntent(
+ unwrapOpenApiEnvelope(
+ result,
+ ),
+ )
+ },
+}
+
export const accountApi = {
async initiateMerge(request: MergeInitiateRequest): Promise {
return fetchJson('/api/v1/account/merge/initiate', {
diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts
index 706fe894..22773753 100644
--- a/web/src/api/generated/schema.d.ts
+++ b/web/src/api/generated/schema.d.ts
@@ -1364,6 +1364,142 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/auth/identity-link-intents/{intentId}/unlink": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Complete unlink after fresh reauthentication */
+ post: operations["completeUnlink"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/local": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Freshly reauthenticate with the local password */
+ post: operations["reauthenticateLocal"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/credential": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Freshly reauthenticate with a credential provider */
+ post: operations["reauthenticateCredential"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/browser": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Start browser-provider fresh reauthentication */
+ post: operations["prepareBrowserReauthentication"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/identity-link-intents/{intentId}/link/credential": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Authenticate and link a credential-provider identity */
+ post: operations["linkCredential"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/identity-link-intents/{intentId}/link/browser": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Start browser authentication for the target identity */
+ post: operations["prepareBrowserLink"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/identity-link-intents/unlink": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Create an external identity unlink intent */
+ post: operations["createUnlinkIntent"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/identity-link-intents/link": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Create an external identity link intent */
+ post: operations["createLinkIntent"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/auth/direct/login": {
parameters: {
query?: never;
@@ -3064,6 +3200,44 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/auth/identity-links": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List linked and available login methods
+ * @description Returns active external bindings and providers that can be linked.
+ */
+ get: operations["accountState"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/identity-link-intents/{intentId}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get an identity link intent */
+ get: operations["getIntent"];
+ put?: never;
+ post?: never;
+ /** Cancel an identity link intent */
+ delete: operations["cancel"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/admin/users": {
parameters: {
query?: never;
@@ -3900,6 +4074,72 @@ export interface components {
currentPassword: string;
newPassword: string;
};
+ IdentityLinkErrorResponse: {
+ /** Format: int32 */
+ code?: number;
+ msg?: string;
+ /** @enum {string} */
+ reasonCode: "INTENT_NOT_FOUND" | "REAUTHENTICATION_REQUIRED" | "SESSION_MISMATCH" | "INTENT_EXPIRED" | "ALREADY_CONSUMED" | "ACTIVE_INTENT_EXISTS" | "ACCOUNT_NOT_ELIGIBLE" | "PROVIDER_UNAVAILABLE" | "PROVIDER_AUTHENTICATION_FAILED" | "ALREADY_LINKED" | "IDENTITY_IN_USE" | "FINAL_LOGIN_METHOD" | "INVALID_OPERATION";
+ /** Format: date-time */
+ timestamp?: string;
+ requestId?: string;
+ };
+ ApiResponseIdentityLinkIntentResponse: {
+ /** Format: int32 */
+ code?: number;
+ msg?: string;
+ data?: components["schemas"]["IdentityLinkIntentResponse"];
+ /** Format: date-time */
+ timestamp?: string;
+ requestId?: string;
+ };
+ IdentityLinkIntentResponse: {
+ /** Format: uuid */
+ id?: string;
+ /** @enum {string} */
+ operation?: "LINK" | "UNLINK";
+ /** @enum {string} */
+ status?: "PENDING_REAUTHENTICATION" | "READY" | "COMPLETED" | "EXPIRED" | "CANCELLED";
+ providerCode?: string;
+ /** Format: int64 */
+ targetBindingId?: number;
+ /** Format: date-time */
+ expiresAt?: string;
+ };
+ IdentityLinkLocalReauthenticationRequest: {
+ password: string;
+ };
+ IdentityLinkCredentialRequest: {
+ providerCode: string;
+ username: string;
+ password: string;
+ };
+ IdentityLinkBrowserStartRequest: {
+ providerCode: string;
+ };
+ ApiResponseIdentityLinkBrowserStartResponse: {
+ /** Format: int32 */
+ code?: number;
+ msg?: string;
+ data?: components["schemas"]["IdentityLinkBrowserStartResponse"];
+ /** Format: date-time */
+ timestamp?: string;
+ requestId?: string;
+ };
+ IdentityLinkBrowserStartResponse: {
+ actionUrl?: string;
+ };
+ IdentityLinkTargetCredentialRequest: {
+ username: string;
+ password: string;
+ };
+ CreateIdentityUnlinkRequest: {
+ /** Format: int64 */
+ bindingId: number;
+ };
+ CreateIdentityLinkRequest: {
+ providerCode: string;
+ };
DirectLoginRequest: {
provider: string;
username: string;
@@ -4886,6 +5126,34 @@ export interface components {
displayName?: string;
actionUrl?: string;
};
+ ApiResponseIdentityLinkAccountStateResponse: {
+ /** Format: int32 */
+ code?: number;
+ msg?: string;
+ data?: components["schemas"]["IdentityLinkAccountStateResponse"];
+ /** Format: date-time */
+ timestamp?: string;
+ requestId?: string;
+ };
+ IdentityLinkAccountStateResponse: {
+ localPasswordEnabled?: boolean;
+ linkedProviders?: components["schemas"]["IdentityLinkBindingResponse"][];
+ availableProviders?: components["schemas"]["IdentityLinkProviderResponse"][];
+ };
+ IdentityLinkBindingResponse: {
+ /** Format: int64 */
+ bindingId?: number;
+ providerCode?: string;
+ displayName?: string;
+ methodTypes?: ("OAUTH_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[];
+ usable?: boolean;
+ canUnlink?: boolean;
+ };
+ IdentityLinkProviderResponse: {
+ providerCode?: string;
+ displayName?: string;
+ methodTypes?: ("OAUTH_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[];
+ };
AdminUserSummaryResponse: {
id?: string;
username?: string;
@@ -7966,6 +8234,706 @@ export interface operations {
};
};
};
+ completeUnlink: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ intentId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ reauthenticateLocal: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ intentId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["IdentityLinkLocalReauthenticationRequest"];
+ };
+ };
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ reauthenticateCredential: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ intentId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["IdentityLinkCredentialRequest"];
+ };
+ };
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ prepareBrowserReauthentication: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ intentId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["IdentityLinkBrowserStartRequest"];
+ };
+ };
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkBrowserStartResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ linkCredential: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ intentId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["IdentityLinkTargetCredentialRequest"];
+ };
+ };
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ prepareBrowserLink: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ intentId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkBrowserStartResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ createUnlinkIntent: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateIdentityUnlinkRequest"];
+ };
+ };
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ createLinkIntent: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateIdentityLinkRequest"];
+ };
+ };
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
directLogin: {
parameters: {
query?: never;
@@ -10624,6 +11592,259 @@ export interface operations {
};
};
};
+ accountState: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkAccountStateResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ getIntent: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ intentId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
+ cancel: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ intentId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Operation completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"];
+ };
+ };
+ /** @description Invalid identity link operation */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Fresh reauthentication required */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent belongs to another session */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity Link intent was not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity conflict, consumed intent, or final login method */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Intent expired */
+ 410: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ /** @description Identity provider unavailable */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["IdentityLinkErrorResponse"];
+ };
+ };
+ };
+ };
listUsers: {
parameters: {
query?: {
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index 60bef288..bd3d19d5 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -61,6 +61,57 @@ export interface ChangePasswordRequest {
newPassword: string
}
+type IdentityLinkIntentSchema = components['schemas']['IdentityLinkIntentResponse']
+type IdentityLinkBindingSchema = components['schemas']['IdentityLinkBindingResponse']
+type IdentityLinkProviderSchema = components['schemas']['IdentityLinkProviderResponse']
+
+export type IdentityLinkOperation = NonNullable
+export type IdentityLinkIntentStatus = NonNullable
+export type IdentityProviderLoginMethodType =
+ NonNullable[number]
+
+export type IdentityLinkIntent = Omit<
+ IdentityLinkIntentSchema,
+ 'id' | 'operation' | 'status' | 'providerCode' | 'expiresAt'
+> & {
+ id: string
+ operation: IdentityLinkOperation
+ status: IdentityLinkIntentStatus
+ providerCode: string
+ targetBindingId?: number
+ expiresAt: string
+}
+
+export type IdentityLinkBinding = Omit<
+ IdentityLinkBindingSchema,
+ 'bindingId' | 'providerCode' | 'displayName' | 'methodTypes' | 'usable' | 'canUnlink'
+> & {
+ bindingId: number
+ providerCode: string
+ displayName: string
+ methodTypes: IdentityProviderLoginMethodType[]
+ usable: boolean
+ canUnlink: boolean
+}
+
+export type IdentityLinkProvider = Omit<
+ IdentityLinkProviderSchema,
+ 'providerCode' | 'displayName' | 'methodTypes'
+> & {
+ providerCode: string
+ displayName: string
+ methodTypes: IdentityProviderLoginMethodType[]
+}
+
+export interface IdentityLinkAccountState {
+ localPasswordEnabled: boolean
+ linkedProviders: IdentityLinkBinding[]
+ availableProviders: IdentityLinkProvider[]
+}
+
+export type IdentityLinkCredentialRequest =
+ components['schemas']['IdentityLinkTargetCredentialRequest']
+
export interface PasswordResetRequest {
email: string
}
diff --git a/web/src/features/auth/identity-link-manager.test.tsx b/web/src/features/auth/identity-link-manager.test.tsx
new file mode 100644
index 00000000..8b66db00
--- /dev/null
+++ b/web/src/features/auth/identity-link-manager.test.tsx
@@ -0,0 +1,205 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { IdentityLinkAccountState } from '@/api/types'
+
+let accountState: IdentityLinkAccountState
+
+function mutation() {
+ return {
+ isPending: false,
+ mutateAsync: vi.fn(),
+ }
+}
+
+vi.mock('react-i18next', async () => {
+ const actual = await vi.importActual(
+ 'react-i18next',
+ )
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ }
+})
+
+vi.mock('@/api/client', () => ({
+ buildApiUrl: (value: string) => value,
+}))
+
+vi.mock('./use-identity-links', () => ({
+ useIdentityLinkAccountState: () => ({
+ data: accountState,
+ isLoading: false,
+ error: null,
+ }),
+ useIdentityLinkIntent: () => ({
+ data: undefined,
+ isLoading: false,
+ error: null,
+ }),
+ useIdentityLinkActions: () => ({
+ createLink: mutation(),
+ createUnlink: mutation(),
+ cancel: mutation(),
+ reauthenticateLocal: mutation(),
+ prepareBrowserReauthentication: mutation(),
+ reauthenticateCredential: mutation(),
+ prepareBrowserLink: mutation(),
+ linkCredential: mutation(),
+ completeUnlink: mutation(),
+ }),
+}))
+
+import {
+ IdentityLinkManager,
+ parseIdentityLinkCallback,
+ resumableIdentityLinkIntentId,
+} from './identity-link-manager'
+
+beforeEach(() => {
+ vi.stubGlobal('window', {
+ location: {
+ search: '',
+ assign: vi.fn(),
+ },
+ })
+ accountState = {
+ localPasswordEnabled: true,
+ linkedProviders: [
+ {
+ bindingId: 41,
+ providerCode: 'github',
+ displayName: 'GitHub',
+ methodTypes: ['OAUTH_REDIRECT'],
+ usable: true,
+ canUnlink: true,
+ },
+ ],
+ availableProviders: [
+ {
+ providerCode: 'oidc',
+ displayName: 'Company OIDC',
+ methodTypes: ['OAUTH_REDIRECT'],
+ },
+ ],
+ }
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('parseIdentityLinkCallback', () => {
+ it('accepts only supported callback results', () => {
+ expect(parseIdentityLinkCallback(
+ '?identityLink=reauthenticated&intentId=intent-1',
+ )).toEqual({
+ result: 'reauthenticated',
+ intentId: 'intent-1',
+ })
+ expect(parseIdentityLinkCallback(
+ '?identityLink=unexpected&intentId=intent-1',
+ )).toEqual({})
+ expect(parseIdentityLinkCallback(
+ '?identityLink=failed&intentId=intent-1&reasonCode=PROVIDER_UNAVAILABLE',
+ )).toEqual({
+ result: 'failed',
+ intentId: 'intent-1',
+ reasonCode: 'PROVIDER_UNAVAILABLE',
+ })
+ expect(parseIdentityLinkCallback(
+ '?identityLink=failed&intentId=intent-1&reasonCode=UNKNOWN_UPPERCASE_CODE',
+ )).toEqual({
+ result: 'failed',
+ intentId: 'intent-1',
+ })
+ })
+
+ it('resumes failed or reauthenticated intents but not completed links', () => {
+ expect(resumableIdentityLinkIntentId({
+ result: 'failed',
+ intentId: 'intent-1',
+ })).toBe('intent-1')
+ expect(resumableIdentityLinkIntentId({
+ result: 'reauthenticated',
+ intentId: 'intent-2',
+ })).toBe('intent-2')
+ expect(resumableIdentityLinkIntentId({
+ result: 'linked',
+ intentId: 'intent-3',
+ })).toBeUndefined()
+ })
+})
+
+describe('IdentityLinkManager', () => {
+ it('renders local, linked, and available login methods', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('security.identityLinks.localPassword')
+ expect(html).toContain('GitHub')
+ expect(html).toContain('Company OIDC')
+ expect(html).toContain('security.identityLinks.remove')
+ expect(html).toContain('security.identityLinks.add')
+ })
+
+ it('disables removal when the binding is the final login method', () => {
+ accountState = {
+ localPasswordEnabled: false,
+ linkedProviders: [{
+ bindingId: 42,
+ providerCode: 'github',
+ displayName: 'GitHub',
+ methodTypes: ['OAUTH_REDIRECT'],
+ usable: true,
+ canUnlink: false,
+ }],
+ availableProviders: [],
+ }
+
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('security.identityLinks.finalMethodHint')
+ expect(html).toMatch(/