package httpapi import ( "crypto/sha256" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "sync" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) type identityConfigurationView struct { Active *identity.Revision `json:"active"` Draft *identity.Revision `json:"draft"` Previous *identity.Revision `json:"previous"` Pairing *identity.PairingExchange `json:"pairing,omitempty"` Runtime identityRuntimeStatus `json:"runtime"` } type identityRuntimeStatus struct { Enabled bool `json:"enabled"` RevisionID string `json:"revisionId,omitempty"` Login string `json:"login"` JIT string `json:"jit"` TokenIntrospection string `json:"tokenIntrospection"` SessionRevocation string `json:"sessionRevocation"` LastTraceID string `json:"lastTraceId,omitempty"` LastAuditID string `json:"lastAuditId,omitempty"` LastErrorCategory string `json:"lastErrorCategory,omitempty"` } type publicIdentityConfiguration struct { Enabled bool `json:"enabled"` OIDCLogin bool `json:"oidcLogin"` LoginURL string `json:"loginUrl,omitempty"` LogoutURL string `json:"logoutUrl,omitempty"` Status string `json:"status"` } type identityPolicyPatch struct { LocalTenantKey *string `json:"localTenantKey"` RolePrefix *string `json:"rolePrefix"` JITEnabled *bool `json:"jitEnabled"` LegacyJWTEnabled *bool `json:"legacyJwtEnabled"` SessionIdleSeconds *int `json:"sessionIdleSeconds"` SessionAbsoluteSeconds *int `json:"sessionAbsoluteSeconds"` SessionRefreshSeconds *int `json:"sessionRefreshSeconds"` } type storedIdentityResponse struct { Status int `json:"status"` Body json.RawMessage `json:"body"` ETag string `json:"etag,omitempty"` AuditID string `json:"auditId,omitempty"` } type identityWriteOperation struct { operation, key, requestHash string release func() once sync.Once } func (operation *identityWriteOperation) close() { if operation != nil { operation.once.Do(operation.release) } } // getPublicIdentityConfiguration godoc // @Summary 获取公开统一认证状态 // @Description 返回 Web 运行时需要的启用状态和非敏感登录、退出入口,不返回 Client、Secret 或内部标识。 // @Tags identity // @Produce json // @Success 200 {object} publicIdentityConfiguration // @Router /api/v1/public/identity [get] func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Cache-Control", "no-store") runtime := s.currentIdentityRuntime() if runtime == nil || runtime.Revision.State != identity.RevisionActive { writeJSON(w, http.StatusOK, publicIdentityConfiguration{Status: "disabled"}) return } view := publicIdentityConfiguration{Enabled: true, Status: "active"} if oidcRuntimeReady(runtime) { view.OIDCLogin = true view.LoginURL = "/api/v1/auth/oidc/login" view.LogoutURL = "/api/v1/auth/oidc/logout" } writeJSON(w, http.StatusOK, view) } // getIdentityConfiguration godoc // @Summary 获取统一认证配置 // @Description 返回 Active、Draft、Previous Revision、配对进度和脱敏运行时健康状态。 // @Tags identity // @Produce json // @Security BearerAuth // @Success 200 {object} identityConfigurationView // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 503 {object} ErrorEnvelope // @Router /api/admin/system/identity/configuration [get] func (s *Server) getIdentityConfiguration(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") view := identityConfigurationView{Runtime: s.identityRuntimeStatus(r)} if revision, err := s.store.ActiveIdentityConfigurationRevision(r.Context()); err == nil { view.Active = &revision } else if !errors.Is(err, identity.ErrRevisionNotFound) { writeError(w, http.StatusServiceUnavailable, "统一认证配置暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE") return } if revision, err := s.store.LatestInactiveIdentityConfigurationRevision(r.Context()); err == nil { view.Draft = &revision if pairing, pairingErr := s.store.IdentityPairingExchangeForRevision(r.Context(), revision.ID); pairingErr == nil { view.Pairing = &pairing } } else if !errors.Is(err, identity.ErrRevisionNotFound) { writeError(w, http.StatusServiceUnavailable, "统一认证草稿暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE") return } if revision, err := s.store.LatestSupersededIdentityConfigurationRevision(r.Context()); err == nil { view.Previous = &revision } else if !errors.Is(err, identity.ErrRevisionNotFound) { writeError(w, http.StatusServiceUnavailable, "统一认证历史版本暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE") return } writeJSON(w, http.StatusOK, view) } // startIdentityPairing godoc // @Summary 启动统一认证配对 // @Description 使用一次性接入码创建 Draft 和异步 Exchange。接入码仅允许出现在请求 Body。 // @Tags identity // @Accept json // @Produce json // @Security BearerAuth // @Param Idempotency-Key header string true "幂等键" // @Param If-Match header string true "首次配对固定为 W/\"0\"" // @Param body body identity.PairingInput true "配对参数" // @Success 202 {object} identity.PairingExchange // @Failure 400 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 409 {object} ErrorEnvelope // @Failure 428 {object} ErrorEnvelope // @Router /api/admin/system/identity/pairings [post] func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) { var input identity.PairingInput if !decodeIdentityRequest(w, r, &input) { return } operation, ok := s.beginIdentityWrite(w, r, "pairing.start", 0, input) if !ok { return } defer operation.close() traceID := ensureIdentityTraceID(w, r) pairing, err := s.identityPairing.Start(r.Context(), input, traceID) if err != nil { s.writeIdentityError(w, r, "pairing.start", "", traceID, err) return } auditID := s.recordIdentityConfigurationAudit(r, "pairing.start", pairing.RevisionID, "accepted", traceID, "") pairing.AuthCenterAuditID = "" s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID) s.startIdentityPairingWorker(pairing.ID) } // getIdentityPairing godoc // @Summary 查询统一认证配对状态 // @Tags identity // @Produce json // @Security BearerAuth // @Param pairingID path string true "配对 ID" // @Success 200 {object} identity.PairingExchange // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope // @Router /api/admin/system/identity/pairings/{pairingID} [get] func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") pairing, err := s.store.IdentityPairingExchange(r.Context(), r.PathValue("pairingID")) if err != nil { s.writeIdentityError(w, r, "pairing.get", r.PathValue("pairingID"), ensureIdentityTraceID(w, r), err) return } w.Header().Set("ETag", identityETag(pairing.Version)) writeJSON(w, http.StatusOK, pairing) } // updateIdentityDraftPolicy godoc // @Summary 修改统一认证 Draft 策略 // @Tags identity // @Accept json // @Produce json // @Security BearerAuth // @Param revisionID path string true "Revision ID" // @Param Idempotency-Key header string true "幂等键" // @Param If-Match header string true "当前 Revision ETag" // @Param body body identityPolicyPatch true "Gateway 本地策略" // @Success 200 {object} identity.Revision // @Failure 400 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 412 {object} ErrorEnvelope // @Failure 428 {object} ErrorEnvelope // @Router /api/admin/system/identity/revisions/{revisionID} [patch] func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Request) { var patch identityPolicyPatch if !decodeIdentityRequest(w, r, &patch) { return } expectedVersion, ok := requiredIdentityVersion(w, r) if !ok { return } operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.policy", expectedVersion, patch) if !ok { return } defer operation.close() revision, err := s.store.IdentityConfigurationRevision(r.Context(), r.PathValue("revisionID")) if err != nil || revision.Version != expectedVersion { s.writeIdentityError(w, r, "revision.policy", r.PathValue("revisionID"), ensureIdentityTraceID(w, r), firstIdentityError(err, identity.ErrRevisionConflict)) return } policy := identity.RevisionPolicy{ LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled, LegacyJWTEnabled: revision.LegacyJWTEnabled, SessionIdleSeconds: revision.SessionIdleSeconds, SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds, } applyIdentityPolicyPatch(&policy, patch) updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy) if err != nil { s.writeIdentityError(w, r, "revision.policy", revision.ID, ensureIdentityTraceID(w, r), err) return } traceID := ensureIdentityTraceID(w, r) auditID := s.recordIdentityConfigurationAudit(r, "revision.policy", revision.ID, "success", traceID, "") s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID) } // validateIdentityRevision godoc // @Summary 验证统一认证 Revision // @Tags identity // @Produce json // @Security BearerAuth // @Param revisionID path string true "Revision ID" // @Param Idempotency-Key header string true "幂等键" // @Param If-Match header string true "当前 Revision ETag" // @Success 200 {object} identity.Revision // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 412 {object} ErrorEnvelope // @Failure 428 {object} ErrorEnvelope // @Failure 502 {object} ErrorEnvelope // @Router /api/admin/system/identity/revisions/{revisionID}/validate [post] func (s *Server) validateIdentityRevision(w http.ResponseWriter, r *http.Request) { s.runIdentityRevisionAction(w, r, "revision.validate", func(expected int64, traceID, auditID string) (identity.Revision, error) { return s.identityRuntime.Validate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) }) } // activateIdentityRevision godoc // @Summary 激活统一认证 Revision // @Description 在完整候选 Runtime 验证成功且本地 Break-glass Manager 可用后原子热切换。 // @Tags identity // @Produce json // @Security BearerAuth // @Param revisionID path string true "Revision ID" // @Param Idempotency-Key header string true "幂等键" // @Param If-Match header string true "当前 Revision ETag" // @Success 200 {object} identity.Revision // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 409 {object} ErrorEnvelope // @Failure 412 {object} ErrorEnvelope // @Failure 428 {object} ErrorEnvelope // @Router /api/admin/system/identity/revisions/{revisionID}/activate [post] func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request) { s.runIdentityRevisionAction(w, r, "revision.activate", func(expected int64, traceID, auditID string) (identity.Revision, error) { return s.identityRuntime.Activate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) }) } // rollbackIdentityRevision godoc // @Summary 回滚统一认证 Revision // @Tags identity // @Produce json // @Security BearerAuth // @Param revisionID path string true "Revision ID" // @Param Idempotency-Key header string true "幂等键" // @Param If-Match header string true "当前 Revision ETag" // @Success 200 {object} identity.Revision // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 409 {object} ErrorEnvelope // @Failure 412 {object} ErrorEnvelope // @Failure 428 {object} ErrorEnvelope // @Router /api/admin/system/identity/revisions/{revisionID}/rollback [post] func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request) { s.runIdentityRevisionAction(w, r, "revision.rollback", func(expected int64, traceID, auditID string) (identity.Revision, error) { return s.identityRuntime.Rollback(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) }) } // disableIdentityConfiguration godoc // @Summary 禁用统一认证 // @Description 保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。 // @Tags identity // @Produce json // @Security BearerAuth // @Param Idempotency-Key header string true "幂等键" // @Param If-Match header string true "当前 Active Revision ETag" // @Success 200 {object} identity.Revision // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 409 {object} ErrorEnvelope // @Failure 412 {object} ErrorEnvelope // @Failure 428 {object} ErrorEnvelope // @Router /api/admin/system/identity/disable [post] func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Request) { expectedVersion, ok := requiredIdentityVersion(w, r) if !ok { return } operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.disable", expectedVersion, struct{}{}) if !ok { return } defer operation.close() traceID := ensureIdentityTraceID(w, r) auditID := s.recordIdentityConfigurationAudit(r, "revision.disable", "active", "requested", traceID, "") disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID) if err != nil { s.writeIdentityError(w, r, "revision.disable", "active", traceID, err) return } s.completeIdentityWrite(w, r, operation, http.StatusOK, disabled, disabled.Version, auditID) } func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Request, action string, run func(int64, string, string) (identity.Revision, error)) { expectedVersion, ok := requiredIdentityVersion(w, r) if !ok { return } operation, ok := s.beginIdentityWriteWithVersion(w, r, action, expectedVersion, struct{}{}) if !ok { return } defer operation.close() traceID := ensureIdentityTraceID(w, r) auditID := s.recordIdentityConfigurationAudit(r, action, r.PathValue("revisionID"), "requested", traceID, "") revision, err := run(expectedVersion, traceID, auditID) if err != nil { s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err) return } s.completeIdentityWrite(w, r, operation, http.StatusOK, revision, revision.Version, auditID) } func (s *Server) identityRuntimeStatus(r *http.Request) identityRuntimeStatus { runtime := s.currentIdentityRuntime() if runtime == nil { return identityRuntimeStatus{Login: "disabled", JIT: "disabled", TokenIntrospection: "disabled", SessionRevocation: "disabled"} } revision := runtime.Revision status := identityRuntimeStatus{ Enabled: true, RevisionID: revision.ID, Login: capabilityHealth(runtime.PublicClient != nil), JIT: capabilityHealth(revision.JITEnabled), TokenIntrospection: capabilityHealth(revision.TokenIntrospection), SessionRevocation: capabilityHealth(revision.SessionRevocation), LastTraceID: revision.LastTraceID, LastAuditID: revision.LastAuditID, LastErrorCategory: revision.LastErrorCategory, } if revision.SessionRevocation && runtime.SecurityEvents != nil { if connection, err := runtime.SecurityEvents.Get(r.Context()); err == nil && connection.LifecycleStatus != "active" { status.SessionRevocation = connection.HealthMode } } return status } func capabilityHealth(enabled bool) string { if enabled { return "healthy" } return "disabled" } func decodeIdentityRequest(w http.ResponseWriter, r *http.Request, output any) bool { r.Body = http.MaxBytesReader(w, r.Body, 32*1024) decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(output); err != nil { writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST") return false } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST") return false } return true } func (s *Server) beginIdentityWrite(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) { if parsed, ok := requiredIdentityVersion(w, r); !ok || parsed != version { if ok { writeError(w, http.StatusPreconditionFailed, "统一认证配置版本已变化", "IDENTITY_VERSION_CONFLICT") } return nil, false } return s.beginIdentityWriteWithVersion(w, r, operation, version, request) } func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) { key := strings.TrimSpace(r.Header.Get("Idempotency-Key")) if key == "" || len(key) > 255 { writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED") return nil, false } encoded, _ := json.Marshal(request) digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...)) requestHash := fmt.Sprintf("%x", digest[:]) s.identityManagementMu.Lock() write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock} if s.store == nil { return write, true } recorded, err := s.store.IdentityManagementRequest(r.Context(), operation, key) if errors.Is(err, store.ErrIdentityManagementRequestNotFound) { return write, true } if err != nil { write.close() writeError(w, http.StatusServiceUnavailable, "幂等状态暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE") return nil, false } if recorded.RequestHash != requestHash { write.close() writeError(w, http.StatusConflict, "Idempotency-Key 已用于其他请求", "IDEMPOTENCY_KEY_REUSED") return nil, false } var response storedIdentityResponse if json.Unmarshal(recorded.Response, &response) != nil { write.close() writeError(w, http.StatusServiceUnavailable, "幂等响应暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE") return nil, false } write.close() w.Header().Set("Cache-Control", "no-store") w.Header().Set("Idempotent-Replayed", "true") if response.ETag != "" { w.Header().Set("ETag", response.ETag) } if response.AuditID != "" { w.Header().Set("X-Audit-Id", response.AuditID) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(response.Status) _, _ = w.Write(response.Body) return nil, false } func (s *Server) completeIdentityWrite(w http.ResponseWriter, r *http.Request, operation *identityWriteOperation, status int, payload any, version int64, auditID string) { body, _ := json.Marshal(payload) stored, _ := json.Marshal(storedIdentityResponse{Status: status, Body: body, ETag: identityETag(version), AuditID: auditID}) if s.store != nil { if err := s.store.RecordIdentityManagementRequest(r.Context(), store.IdentityManagementRequest{ Operation: operation.operation, Key: operation.key, RequestHash: operation.requestHash, Response: stored, }); err != nil && s.logger != nil { s.logger.ErrorContext(r.Context(), "identity idempotency response could not be recorded", "operation", operation.operation, "error_category", "idempotency_store_failed") } } operation.close() w.Header().Set("Cache-Control", "no-store") w.Header().Set("ETag", identityETag(version)) if auditID != "" { w.Header().Set("X-Audit-Id", auditID) } writeJSON(w, status, payload) } func requiredIdentityVersion(w http.ResponseWriter, r *http.Request) (int64, bool) { value := strings.Trim(strings.TrimPrefix(strings.TrimSpace(r.Header.Get("If-Match")), "W/"), `"`) version, err := strconv.ParseInt(value, 10, 64) if err != nil || version < 0 { writeError(w, http.StatusPreconditionRequired, "If-Match is required", "IF_MATCH_REQUIRED") return 0, false } return version, true } func identityETag(version int64) string { return fmt.Sprintf(`W/"%d"`, version) } func applyIdentityPolicyPatch(policy *identity.RevisionPolicy, patch identityPolicyPatch) { if patch.LocalTenantKey != nil { policy.LocalTenantKey = strings.TrimSpace(*patch.LocalTenantKey) } if patch.RolePrefix != nil { policy.RolePrefix = strings.TrimSpace(*patch.RolePrefix) } if patch.JITEnabled != nil { policy.JITEnabled = *patch.JITEnabled } if patch.LegacyJWTEnabled != nil { policy.LegacyJWTEnabled = *patch.LegacyJWTEnabled } if patch.SessionIdleSeconds != nil { policy.SessionIdleSeconds = *patch.SessionIdleSeconds } if patch.SessionAbsoluteSeconds != nil { policy.SessionAbsoluteSeconds = *patch.SessionAbsoluteSeconds } if patch.SessionRefreshSeconds != nil { policy.SessionRefreshSeconds = *patch.SessionRefreshSeconds } } func firstIdentityError(actual, fallback error) error { if actual != nil { return actual } return fallback } func ensureIdentityTraceID(w http.ResponseWriter, r *http.Request) string { return ensureSecurityEventTraceID(w, r) } func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, action, targetID, traceID string, err error) { status, message, code := identityErrorProjection(err) s.recordIdentityConfigurationAudit(r, action, targetID, "failure", traceID, code) writeError(w, status, message, code) } func identityErrorProjection(err error) (int, string, string) { switch { case errors.Is(err, identity.ErrRevisionNotFound): return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND" case errors.Is(err, identity.ErrRevisionConflict): return http.StatusPreconditionFailed, "统一认证配置版本或状态已变化", "IDENTITY_VERSION_CONFLICT" case errors.Is(err, identity.ErrBreakGlassRequired): return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED" case errors.Is(err, identity.ErrLocalTenantInvalid): return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID" case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")): return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID" default: return http.StatusBadGateway, "认证中心或统一认证服务暂时不可用", "IDENTITY_SERVICE_UNAVAILABLE" } } func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targetID, outcome, traceID, errorCategory string) string { if s.store == nil { return "" } actor, _ := auth.UserFromContext(r.Context()) input := store.AuditLogInput{ Category: "identity", Action: "identity." + action, TargetType: "identity_configuration_revision", TargetID: firstNonEmptyText(targetID, "pending"), RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512), Metadata: map[string]any{ "outcome": outcome, "traceId": traceID, "errorCategory": errorCategory, }, } if actor != nil { input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID)) input.ActorUserID, input.ActorUsername, input.ActorSource, input.ActorRoles = actor.ID, actor.Username, actor.Source, actor.Roles } audit, err := s.store.RecordAuditLog(r.Context(), input) if err != nil { if s.logger != nil { s.logger.WarnContext(r.Context(), "record identity audit failed", "action", action, "error_category", "audit_store_failed", "trace_id", traceID) } return "" } return audit.ID } func (s *Server) startIdentityPairingWorker(pairingID string) { if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, struct{}{}); loaded { return } go func() { defer s.identityPairingWorkers.Delete(pairingID) delay := time.Second for { pairing, err := s.identityPairing.Continue(s.ctx, pairingID) if err == nil { if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired { return } delay = time.Second } else { if s.logger != nil { s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", "pairing_step_failed") } if delay < 15*time.Second { delay *= 2 } } select { case <-s.ctx.Done(): return case <-time.After(delay): } } }() }