fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。 验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
This commit is contained in:
@@ -73,6 +73,12 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
if registerResponse.AccessToken == "" {
|
||||
t.Fatal("register did not return access token")
|
||||
}
|
||||
ordinaryUsername := "smoke_user_" + suffixText
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": ordinaryUsername,
|
||||
"email": ordinaryUsername + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, &struct{}{})
|
||||
|
||||
var duplicateResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
@@ -129,6 +135,25 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote smoke user: %v", err)
|
||||
}
|
||||
serverMainCtx, cancelServerMain := context.WithCancel(ctx)
|
||||
serverMain := httptest.NewServer(NewServerWithContext(serverMainCtx, config.Config{
|
||||
AppEnv: "test", HTTPAddr: ":0", DatabaseURL: databaseURL, IdentityMode: "server-main",
|
||||
JWTSecret: "test-secret", CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
var breakGlassLogin struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username, "password": password,
|
||||
}, http.StatusOK, &breakGlassLogin)
|
||||
if breakGlassLogin.AccessToken == "" {
|
||||
t.Fatal("server-main break-glass manager login did not return access token")
|
||||
}
|
||||
doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": ordinaryUsername, "password": password,
|
||||
}, http.StatusForbidden, &map[string]any{})
|
||||
serverMain.Close()
|
||||
cancelServerMain()
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
@@ -1697,6 +1722,9 @@ func TestOriginAllowedSupportsCommaSeparatedOrigins(t *testing.T) {
|
||||
if originAllowed("http://127.0.0.1:5179", allowed) {
|
||||
t.Fatal("unexpected origin should not be allowed")
|
||||
}
|
||||
if originAllowed("https://evil.example.com", "*") {
|
||||
t.Fatal("credentialed wildcard CORS origin should not be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func assertRuntimeRecoveryReleasesPendingRateReservations(t *testing.T, ctx context.Context, db *store.Store) {
|
||||
|
||||
@@ -79,7 +79,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.localIdentityEnabled() {
|
||||
if !s.localIdentityEnabled() || !s.ordinaryLocalJWTEnabled() {
|
||||
writeError(w, http.StatusForbidden, "local registration is disabled")
|
||||
return
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// login godoc
|
||||
// @Summary 本地登录
|
||||
// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。
|
||||
// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -123,10 +123,6 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.localIdentityEnabled() {
|
||||
writeError(w, http.StatusForbidden, "local login is disabled")
|
||||
return
|
||||
}
|
||||
var input store.LocalLoginInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
@@ -142,6 +138,10 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "login failed")
|
||||
return
|
||||
}
|
||||
if !s.localLoginAllowed(user) {
|
||||
writeError(w, http.StatusForbidden, "local login is disabled except for break-glass managers")
|
||||
return
|
||||
}
|
||||
s.writeAuthResponse(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
@@ -150,6 +150,19 @@ func (s *Server) localIdentityEnabled() bool {
|
||||
return mode == "" || mode == "standalone" || mode == "hybrid"
|
||||
}
|
||||
|
||||
func (s *Server) localLoginAllowed(user store.GatewayUser) bool {
|
||||
for _, role := range user.Roles {
|
||||
if role == "manager" || role == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return s.localIdentityEnabled() && s.ordinaryLocalJWTEnabled()
|
||||
}
|
||||
|
||||
func (s *Server) ordinaryLocalJWTEnabled() bool {
|
||||
return s.identityRuntime == nil || s.identityRuntime.LegacyJWTEnabled()
|
||||
}
|
||||
|
||||
func (s *Server) writeAuthResponse(w http.ResponseWriter, status int, user store.GatewayUser) {
|
||||
authUser := authUserFromGatewayUser(user)
|
||||
const ttl = 24 * time.Hour
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -68,6 +69,11 @@ type identityWriteOperation struct {
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type identityPairingWorker struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (operation *identityWriteOperation) close() {
|
||||
if operation != nil {
|
||||
operation.once.Do(operation.release)
|
||||
@@ -163,12 +169,15 @@ func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.start", "pending", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
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)
|
||||
@@ -196,6 +205,95 @@ func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, pairing)
|
||||
}
|
||||
|
||||
// cancelIdentityPairing godoc
|
||||
// @Summary 放弃本地统一认证配对
|
||||
// @Description 原子封存未激活 Revision,并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 Exchange;下一次凭据交付会轮换机器凭据。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pairingID path string true "配对 ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Pairing ETag"
|
||||
// @Success 202 {object} identity.PairingExchange
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/pairings/{pairingID}/cancel [post]
|
||||
func (s *Server) cancelIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairingID := r.PathValue("pairingID")
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.cancel", expectedVersion, struct {
|
||||
PairingID string `json:"pairingId"`
|
||||
}{PairingID: pairingID})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.cancel", pairingID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairing, err := s.identityPairing.Cancel(r.Context(), pairingID, expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.cancel", pairingID, traceID, err)
|
||||
return
|
||||
}
|
||||
done := s.stopIdentityPairingWorker(pairing.ID)
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||
s.startIdentityPairingCleanupWorker(pairing.ID, done)
|
||||
}
|
||||
|
||||
// retireIdentityPairingSecurityEventConflict godoc
|
||||
// @Summary 安全退役阻塞配对的旧 SSF 连接
|
||||
// @Description 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pairingID path string true "配对 ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Pairing ETag"
|
||||
// @Success 202 {object} identity.PairingExchange
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event [post]
|
||||
func (s *Server) retireIdentityPairingSecurityEventConflict(w http.ResponseWriter, r *http.Request) {
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairingID := r.PathValue("pairingID")
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.retire_security_event_conflict", expectedVersion, struct {
|
||||
PairingID string `json:"pairingId"`
|
||||
}{PairingID: pairingID})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.retire_security_event_conflict", pairingID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairing, err := s.identityPairing.RetireConflictingSecurityEvents(r.Context(), pairingID, expectedVersion)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.retire_security_event_conflict", pairingID, traceID, err)
|
||||
return
|
||||
}
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||
s.startIdentityPairingWorker(pairing.ID)
|
||||
}
|
||||
|
||||
// updateIdentityDraftPolicy godoc
|
||||
// @Summary 修改统一认证 Draft 策略
|
||||
// @Tags identity
|
||||
@@ -238,13 +336,16 @@ func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Reques
|
||||
SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds,
|
||||
}
|
||||
applyIdentityPolicyPatch(&policy, patch)
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.policy", revision.ID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
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)
|
||||
s.writeIdentityError(w, r, "revision.policy", revision.ID, traceID, 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)
|
||||
}
|
||||
|
||||
@@ -292,7 +393,8 @@ func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
// rollbackIdentityRevision godoc
|
||||
// @Summary 回滚统一认证 Revision
|
||||
// @Summary 请求恢复历史统一认证 Revision
|
||||
// @Description 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -314,7 +416,7 @@ func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// disableIdentityConfiguration godoc
|
||||
// @Summary 禁用统一认证
|
||||
// @Description 保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。
|
||||
// @Description 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session,并继续允许本地管理登录;重新接入必须使用新的接入码。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -338,7 +440,10 @@ func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID := s.recordIdentityConfigurationAudit(r, "revision.disable", "active", "requested", traceID, "")
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.disable", "active", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "revision.disable", "active", traceID, err)
|
||||
@@ -358,7 +463,10 @@ func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID := s.recordIdentityConfigurationAudit(r, action, r.PathValue("revisionID"), "requested", traceID, "")
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, action, r.PathValue("revisionID"), traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
revision, err := run(expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err)
|
||||
@@ -425,9 +533,7 @@ func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Re
|
||||
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[:])
|
||||
requestHash := identityRequestHash(operation, version, request)
|
||||
s.identityManagementMu.Lock()
|
||||
write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock}
|
||||
if s.store == nil {
|
||||
@@ -468,6 +574,12 @@ func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Re
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func identityRequestHash(operation string, version int64, request any) string {
|
||||
encoded, _ := json.Marshal(request)
|
||||
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
|
||||
return fmt.Sprintf("%x", digest[:])
|
||||
}
|
||||
|
||||
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})
|
||||
@@ -541,6 +653,11 @@ func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, acti
|
||||
}
|
||||
|
||||
func identityErrorProjection(err error) (int, string, string) {
|
||||
var categorized interface{ SafeErrorCategory() string }
|
||||
safeCategory := ""
|
||||
if errors.As(err, &categorized) {
|
||||
safeCategory = categorized.SafeErrorCategory()
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, identity.ErrRevisionNotFound):
|
||||
return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND"
|
||||
@@ -550,6 +667,20 @@ func identityErrorProjection(err error) (int, string, string) {
|
||||
return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED"
|
||||
case errors.Is(err, identity.ErrLocalTenantInvalid):
|
||||
return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID"
|
||||
case errors.Is(err, identity.ErrPairingInProgress):
|
||||
return http.StatusConflict, "请先完成或放弃当前统一认证配对", "IDENTITY_PAIRING_IN_PROGRESS"
|
||||
case errors.Is(err, identity.ErrActiveConfigurationHandoffRequired):
|
||||
return http.StatusConflict, "当前仍有 Active 统一认证配置;请先禁用,再使用新接入码配对", "IDENTITY_ACTIVE_CONFIGURATION_HANDOFF_REQUIRED"
|
||||
case errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired):
|
||||
return http.StatusConflict, "旧版本关联的远端 OAuth/SSF 资源可能已变化;请禁用后使用新接入码恢复", "IDENTITY_ROLLBACK_CONFIGURATION_HANDOFF_REQUIRED"
|
||||
case errors.Is(err, identity.ErrSecurityEventRetirementPending):
|
||||
return http.StatusConflict, "旧安全事件 Stream 尚未完成断开;系统会继续重试,请稍后再次禁用", "IDENTITY_SECURITY_EVENT_RETIREMENT_PENDING"
|
||||
case errors.Is(err, identity.ErrPairingNotCancellable):
|
||||
return http.StatusConflict, "当前统一认证配置不能放弃", "IDENTITY_PAIRING_NOT_CANCELLABLE"
|
||||
case errors.Is(err, identity.ErrPairingConflictNotResolvable):
|
||||
return http.StatusConflict, "当前配对已不需要退役旧安全事件连接,请刷新状态", "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"
|
||||
case safeCategory == "credential_handoff_unsafe":
|
||||
return http.StatusConflict, "旧安全事件连接与本次认证中心不匹配,系统已拒绝发送凭据;请先在原配置下断开旧连接", "IDENTITY_SECURITY_EVENT_HANDOFF_UNSAFE"
|
||||
case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")):
|
||||
return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID"
|
||||
default:
|
||||
@@ -583,23 +714,97 @@ func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targe
|
||||
return audit.ID
|
||||
}
|
||||
|
||||
func (s *Server) requireIdentityConfigurationAudit(w http.ResponseWriter, r *http.Request, action, targetID, traceID string) (string, bool) {
|
||||
auditID := s.recordIdentityConfigurationAudit(r, action, targetID, "requested", traceID, "")
|
||||
if auditID == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "统一认证审计暂时不可用,操作未执行", "IDENTITY_AUDIT_UNAVAILABLE")
|
||||
return "", false
|
||||
}
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
return auditID, true
|
||||
}
|
||||
|
||||
func (s *Server) startIdentityPairingWorker(pairingID string) {
|
||||
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
|
||||
workerContext, cancel := context.WithCancel(s.ctx)
|
||||
worker := &identityPairingWorker{cancel: cancel, done: make(chan struct{})}
|
||||
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, worker); loaded {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer s.identityPairingWorkers.Delete(pairingID)
|
||||
defer func() {
|
||||
s.identityPairingWorkers.CompareAndDelete(pairingID, worker)
|
||||
close(worker.done)
|
||||
}()
|
||||
delay := time.Second
|
||||
for {
|
||||
pairing, err := s.identityPairing.Continue(s.ctx, pairingID)
|
||||
pairing, err := s.identityPairing.Continue(workerContext, pairingID)
|
||||
if err == nil {
|
||||
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired {
|
||||
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired || pairing.Status == identity.PairingCancelled {
|
||||
return
|
||||
}
|
||||
delay = time.Second
|
||||
} else {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", "pairing_step_failed")
|
||||
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_step_failed"))
|
||||
}
|
||||
if delay < 15*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-workerContext.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) stopIdentityPairingWorker(pairingID string) <-chan struct{} {
|
||||
if value, ok := s.identityPairingWorkers.Load(pairingID); ok {
|
||||
worker := value.(*identityPairingWorker)
|
||||
worker.cancel()
|
||||
return worker.done
|
||||
}
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
return done
|
||||
}
|
||||
|
||||
func (s *Server) startIdentityPairingCleanupWorker(pairingID string, processingDone <-chan struct{}) {
|
||||
if processingDone == nil {
|
||||
processingDone = s.stopIdentityPairingWorker(pairingID)
|
||||
}
|
||||
if _, loaded := s.identityCleanupWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer s.identityCleanupWorkers.Delete(pairingID)
|
||||
if processingDone != nil {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-processingDone:
|
||||
}
|
||||
}
|
||||
delay := time.Second
|
||||
for {
|
||||
pairing, err := s.identityPairing.Cleanup(s.ctx, pairingID)
|
||||
if err == nil {
|
||||
if pairing.CleanupStatus == identity.PairingCleanupCompleted {
|
||||
return
|
||||
}
|
||||
delay = time.Second
|
||||
} else {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, identity.ErrPairingNotCancellable) {
|
||||
return
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("identity pairing cleanup failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_cleanup_failed"))
|
||||
}
|
||||
if delay < 15*time.Second {
|
||||
delay *= 2
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
)
|
||||
|
||||
func TestRequiredIdentityWriteHeaders(t *testing.T) {
|
||||
@@ -28,6 +31,46 @@ func TestIdentityErrorProjectionProtectsInternalDetails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingRecoveryErrorsUseStableConflictResponses(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
err error
|
||||
code string
|
||||
}{
|
||||
{err: identity.ErrPairingInProgress, code: "IDENTITY_PAIRING_IN_PROGRESS"},
|
||||
{err: identity.ErrPairingNotCancellable, code: "IDENTITY_PAIRING_NOT_CANCELLABLE"},
|
||||
{err: identity.ErrPairingConflictNotResolvable, code: "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"},
|
||||
} {
|
||||
status, message, code := identityErrorProjection(test.err)
|
||||
if status != http.StatusConflict || code != test.code || message == "" || errors.Is(assertionError(message), test.err) {
|
||||
t.Fatalf("err=%v status=%d code=%q message=%q", test.err, status, code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityWriteHashScopesPairingCancellationToTarget(t *testing.T) {
|
||||
type cancellationRequest struct {
|
||||
PairingID string `json:"pairingId"`
|
||||
}
|
||||
first := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-a"})
|
||||
second := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-b"})
|
||||
if first == second {
|
||||
t.Fatal("pairing cancellation idempotency hash must include the target pairing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityWriteAuditFailsClosedBeforeMutation(t *testing.T) {
|
||||
server := &Server{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
if _, ok := server.requireIdentityConfigurationAudit(recorder, request, "revision.disable", "active", "trace-test"); ok {
|
||||
t.Fatal("identity write unexpectedly continued without durable audit storage")
|
||||
}
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("audit failure status=%d, want 503", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type assertionError string
|
||||
|
||||
func (err assertionError) Error() string { return string(err) }
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
)
|
||||
|
||||
const identityPairingReconcileInterval = 5 * time.Second
|
||||
|
||||
type identityPairingReconciliationStore interface {
|
||||
PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error)
|
||||
CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error)
|
||||
PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error)
|
||||
}
|
||||
|
||||
type identityPairingRestorer interface {
|
||||
RestoreCompletedSecurityEvents(context.Context, string) error
|
||||
}
|
||||
|
||||
func (s *Server) reconcileCanonicalIdentityPairing() {
|
||||
reconcileCanonicalIdentityPairing(
|
||||
s.ctx,
|
||||
s.store,
|
||||
s.identityPairing,
|
||||
&s.identityRestoredPairings,
|
||||
s.startIdentityPairingWorker,
|
||||
func(pairingID string) { s.startIdentityPairingCleanupWorker(pairingID, nil) },
|
||||
s.logger,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) runIdentityPairingCoordinator() {
|
||||
ticker := time.NewTicker(identityPairingReconcileInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.reconcileIdentityRuntime()
|
||||
s.reconcileCanonicalIdentityPairing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileCanonicalIdentityPairing(
|
||||
ctx context.Context,
|
||||
repository identityPairingReconciliationStore,
|
||||
restorer identityPairingRestorer,
|
||||
restored *sync.Map,
|
||||
startWorker func(string),
|
||||
startCleanup func(string),
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
cleanups, cleanupErr := repository.PendingIdentityPairingCleanups(ctx)
|
||||
if cleanupErr != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("pending identity pairing cleanups could not be resumed", "error_category", "pairing_cleanup_resume_failed")
|
||||
}
|
||||
} else {
|
||||
for _, pairing := range cleanups {
|
||||
startCleanup(pairing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
pending, err := repository.PendingIdentityPairingExchanges(ctx)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
forgetOtherRestoredPairings(restored, "")
|
||||
startWorker(pending[0].ID)
|
||||
return
|
||||
}
|
||||
|
||||
completed, err := repository.CompletedIdentityPairingsAwaitingActivation(ctx)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("completed identity pairings could not be loaded", "error_category", "pairing_receiver_restore_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(completed) == 0 {
|
||||
forgetOtherRestoredPairings(restored, "")
|
||||
return
|
||||
}
|
||||
|
||||
pairingID := completed[0].ID
|
||||
forgetOtherRestoredPairings(restored, pairingID)
|
||||
if _, loaded := restored.LoadOrStore(pairingID, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
if err := restorer.RestoreCompletedSecurityEvents(ctx, pairingID); err != nil {
|
||||
restored.Delete(pairingID)
|
||||
if logger != nil {
|
||||
logger.Warn("completed identity pairing receiver could not be restored", "pairing_id", pairingID, "error_category", "pairing_receiver_restore_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) reconcileIdentityRuntime() {
|
||||
if s.identityRuntime == nil || !s.identityRuntime.ReconciliationRequired() {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(s.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.identityRuntime.ReconcileActive(ctx); err != nil && s.logger != nil {
|
||||
s.logger.Warn("identity runtime reconciliation failed and will retry", "error_category", "identity_runtime_reconciliation_failed")
|
||||
}
|
||||
}
|
||||
|
||||
func forgetOtherRestoredPairings(restored *sync.Map, keepID string) {
|
||||
restored.Range(func(key, _ any) bool {
|
||||
if key != keepID {
|
||||
restored.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
)
|
||||
|
||||
type identityPairingCoordinatorStore struct {
|
||||
pending []identity.PairingExchange
|
||||
completed []identity.PairingExchange
|
||||
cleanups []identity.PairingExchange
|
||||
pendingErr error
|
||||
completedErr error
|
||||
cleanupErr error
|
||||
}
|
||||
|
||||
func (store identityPairingCoordinatorStore) PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error) {
|
||||
return store.pending, store.pendingErr
|
||||
}
|
||||
|
||||
func (store identityPairingCoordinatorStore) CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error) {
|
||||
return store.completed, store.completedErr
|
||||
}
|
||||
|
||||
func (store identityPairingCoordinatorStore) PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error) {
|
||||
return store.cleanups, store.cleanupErr
|
||||
}
|
||||
|
||||
type identityPairingCoordinatorRestorer struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (restorer *identityPairingCoordinatorRestorer) RestoreCompletedSecurityEvents(context.Context, string) error {
|
||||
restorer.calls++
|
||||
return restorer.err
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingRetriesFailedRestore(t *testing.T) {
|
||||
repository := identityPairingCoordinatorStore{
|
||||
completed: []identity.PairingExchange{{ID: "pairing-1"}},
|
||||
}
|
||||
restorer := &identityPairingCoordinatorRestorer{err: errors.New("SecretStore temporarily unavailable")}
|
||||
restored := &sync.Map{}
|
||||
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
|
||||
|
||||
if restorer.calls != 2 {
|
||||
t.Fatalf("restore calls = %d, want 2", restorer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingRestoresSuccessfulPairingOnce(t *testing.T) {
|
||||
repository := identityPairingCoordinatorStore{
|
||||
completed: []identity.PairingExchange{{ID: "pairing-1"}},
|
||||
}
|
||||
restorer := &identityPairingCoordinatorRestorer{}
|
||||
restored := &sync.Map{}
|
||||
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
|
||||
|
||||
if restorer.calls != 1 {
|
||||
t.Fatalf("restore calls = %d, want 1", restorer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingPrioritizesPendingWork(t *testing.T) {
|
||||
repository := identityPairingCoordinatorStore{
|
||||
pending: []identity.PairingExchange{{ID: "pending-pairing"}},
|
||||
completed: []identity.PairingExchange{{ID: "completed-pairing"}},
|
||||
}
|
||||
restorer := &identityPairingCoordinatorRestorer{}
|
||||
restored := &sync.Map{}
|
||||
started := ""
|
||||
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(pairingID string) {
|
||||
started = pairingID
|
||||
}, func(string) {}, nil)
|
||||
|
||||
if started != "pending-pairing" {
|
||||
t.Fatalf("started pairing = %q, want pending-pairing", started)
|
||||
}
|
||||
if restorer.calls != 0 {
|
||||
t.Fatalf("restore calls = %d, want 0", restorer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingForgetsTerminalPairing(t *testing.T) {
|
||||
restored := &sync.Map{}
|
||||
restored.Store("terminal-pairing", struct{}{})
|
||||
|
||||
reconcileCanonicalIdentityPairing(
|
||||
context.Background(),
|
||||
identityPairingCoordinatorStore{},
|
||||
&identityPairingCoordinatorRestorer{},
|
||||
restored,
|
||||
func(string) {},
|
||||
func(string) {},
|
||||
nil,
|
||||
)
|
||||
|
||||
if _, ok := restored.Load("terminal-pairing"); ok {
|
||||
t.Fatal("terminal pairing restore marker was not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingResumesCommittedCleanup(t *testing.T) {
|
||||
repository := identityPairingCoordinatorStore{
|
||||
cleanups: []identity.PairingExchange{{ID: "cancelled-pairing"}},
|
||||
}
|
||||
restorer := &identityPairingCoordinatorRestorer{}
|
||||
restored := &sync.Map{}
|
||||
startedCleanup := ""
|
||||
|
||||
reconcileCanonicalIdentityPairing(
|
||||
context.Background(),
|
||||
repository,
|
||||
restorer,
|
||||
restored,
|
||||
func(string) {},
|
||||
func(pairingID string) { startedCleanup = pairingID },
|
||||
nil,
|
||||
)
|
||||
|
||||
if startedCleanup != "cancelled-pairing" {
|
||||
t.Fatalf("started cleanup = %q, want cancelled-pairing", startedCleanup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupCoordinatorCancelsPairingWorkerBeforeCleanup(t *testing.T) {
|
||||
serverContext, cancelServer := context.WithCancel(context.Background())
|
||||
cancelServer()
|
||||
workerContext, cancelWorker := context.WithCancel(context.Background())
|
||||
server := &Server{ctx: serverContext}
|
||||
server.identityPairingWorkers.Store("pairing-1", &identityPairingWorker{cancel: cancelWorker, done: make(chan struct{})})
|
||||
|
||||
server.startIdentityPairingCleanupWorker("pairing-1", nil)
|
||||
|
||||
select {
|
||||
case <-workerContext.Done():
|
||||
default:
|
||||
t.Fatal("coordinator cleanup did not cancel the pairing worker first")
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ func (s *Server) currentIdentityRuntime() *identityRequestRuntime {
|
||||
}
|
||||
|
||||
func (s *Server) currentSecurityEventManager() *ssfreceiver.ConnectionManager {
|
||||
if s.identityRuntime != nil {
|
||||
return s.identityRuntime.SecurityEventManager()
|
||||
}
|
||||
if runtime := s.currentIdentityRuntime(); runtime != nil {
|
||||
return runtime.SecurityEvents
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestLocalLoginPolicyAlwaysPreservesBreakGlassManager(t *testing.T) {
|
||||
serverMain := &Server{cfg: config.Config{IdentityMode: "server-main"}}
|
||||
if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"manager"}}) {
|
||||
t.Fatal("server-main mode rejected a local break-glass manager")
|
||||
}
|
||||
if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"admin"}}) {
|
||||
t.Fatal("server-main mode rejected a local break-glass admin")
|
||||
}
|
||||
if serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) {
|
||||
t.Fatal("server-main mode accepted an ordinary local user")
|
||||
}
|
||||
|
||||
hybrid := &Server{cfg: config.Config{IdentityMode: "hybrid"}}
|
||||
if !hybrid.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) {
|
||||
t.Fatal("hybrid mode rejected an ordinary local user")
|
||||
}
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store,
|
||||
draft, err := identity.NewDraft(identity.PairingInput{
|
||||
AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178",
|
||||
LocalTenantKey: localTenantKey, LegacyJWTEnabled: true,
|
||||
})
|
||||
}, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("create OIDC JIT draft: %v", err)
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store,
|
||||
Capabilities: []string{"oidc_login", "api_access"}, Audience: "gateway-api", Scopes: []string{"gateway.access"},
|
||||
Clients: identity.ManifestClients{BrowserLogin: &identity.ManifestClient{ClientID: "gateway-public-test"}},
|
||||
},
|
||||
SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test",
|
||||
SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test", AppEnv: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply OIDC JIT manifest: %v", err)
|
||||
|
||||
@@ -357,7 +357,7 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
|
||||
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if runtime == nil || !runtime.BrowserEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) {
|
||||
if runtime == nil || !runtime.BrowserEnabled || hasExplicitCredential(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -366,7 +366,7 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" || !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) {
|
||||
if origin != "" && !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) || origin == "" && !isSafeHTTPMethod(r.Method) {
|
||||
writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF)
|
||||
return
|
||||
}
|
||||
@@ -384,8 +384,16 @@ func isSafeHTTPMethod(method string) bool {
|
||||
}
|
||||
|
||||
func hasExplicitCredential(r *http.Request) bool {
|
||||
return strings.TrimSpace(r.Header.Get("Authorization")) != "" ||
|
||||
return extractBearerCredential(r.Header.Get("Authorization")) != "" ||
|
||||
strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" ||
|
||||
strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" ||
|
||||
strings.TrimSpace(r.URL.Query().Get("key")) != ""
|
||||
strings.HasPrefix(strings.TrimSpace(r.URL.Query().Get("key")), "sk-")
|
||||
}
|
||||
|
||||
func extractBearerCredential(value string) string {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
|
||||
return fields[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
|
||||
}{
|
||||
{name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden},
|
||||
{name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden},
|
||||
{name: "foreign origin cannot read cookie authenticated data", method: http.MethodGet, origin: "https://evil.example", wantStatus: http.StatusForbidden},
|
||||
{name: "allowed origin", method: http.MethodPatch, origin: "https://gateway.example.com", wantStatus: http.StatusNoContent},
|
||||
{name: "safe request", method: http.MethodGet, wantStatus: http.StatusNoContent},
|
||||
{name: "explicit bearer bypasses cookie csrf", method: http.MethodPost, bearer: true, wantStatus: http.StatusNoContent},
|
||||
@@ -181,6 +182,94 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionCSRFMalformedAuthorizationCannotBypassForeignOrigin(t *testing.T) {
|
||||
authenticator := auth.New("local-jwt-secret", "", "")
|
||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*auth.User, error) {
|
||||
return &auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, nil
|
||||
}
|
||||
server := &Server{
|
||||
auth: authenticator,
|
||||
identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"},
|
||||
identityTestBrowserEnabled: true,
|
||||
}
|
||||
called := false
|
||||
handler := server.protectOIDCSessionCookie(server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
|
||||
request.Header.Set("Origin", "https://evil.example.com")
|
||||
request.Header.Set("Authorization", "malformed")
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "manager-session"})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden || called {
|
||||
t.Fatalf("malformed Authorization CSRF status=%d handler_called=%t", recorder.Code, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRouteRejectsManagerJWTInQueryAndAcceptsAuthorizationHeader(t *testing.T) {
|
||||
authenticator := auth.New("local-jwt-secret", "", "")
|
||||
managerToken, err := authenticator.SignJWT(&auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{auth: authenticator}
|
||||
handler := server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
queryRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil)
|
||||
queryRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(queryRecorder, queryRequest)
|
||||
if queryRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("query manager JWT status=%d, want 401", queryRecorder.Code)
|
||||
}
|
||||
|
||||
headerRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
|
||||
headerRequest.Header.Set("Authorization", "Bearer "+managerToken)
|
||||
headerRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(headerRecorder, headerRequest)
|
||||
if headerRecorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("Authorization manager JWT status=%d, want 204", headerRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSUsesOnlyCurrentActiveIdentityWebOriginWithoutRestart(t *testing.T) {
|
||||
server := &Server{
|
||||
cfg: config.Config{CORSAllowedOrigin: "https://bootstrap.example.com"},
|
||||
identityTestRevision: identity.Revision{
|
||||
ID: "active-revision", State: identity.RevisionActive, WebBaseURL: "https://gateway.example.com",
|
||||
},
|
||||
}
|
||||
handler := server.cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
request := func(origin string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodOptions, "/api/admin/system/identity/configuration", nil)
|
||||
r.Header.Set("Origin", origin)
|
||||
r.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
active := request("https://gateway.example.com")
|
||||
if active.Header().Get("Access-Control-Allow-Origin") != "https://gateway.example.com" || active.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Fatalf("active Web origin headers=%v", active.Header())
|
||||
}
|
||||
if evil := request("https://evil.example.com"); evil.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatalf("evil origin was allowed: headers=%v", evil.Header())
|
||||
}
|
||||
|
||||
server.identityTestRevision = identity.Revision{}
|
||||
if disabled := request("https://gateway.example.com"); disabled.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatalf("disabled Revision retained dynamic origin: headers=%v", disabled.Header())
|
||||
}
|
||||
if bootstrap := request("https://bootstrap.example.com"); bootstrap.Header().Get("Access-Control-Allow-Origin") != "https://bootstrap.example.com" {
|
||||
t.Fatalf("deployment bootstrap origin stopped working: headers=%v", bootstrap.Header())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
|
||||
server := &Server{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||||
|
||||
@@ -78,10 +78,28 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque
|
||||
defer clear(secret)
|
||||
secretDigest := sha256.Sum256(secret)
|
||||
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:]))
|
||||
s.securityEventManagementMu.Lock()
|
||||
defer s.securityEventManagementMu.Unlock()
|
||||
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
|
||||
return
|
||||
}
|
||||
if current, err := manager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) {
|
||||
current, currentErr := manager.Get(r.Context())
|
||||
switch {
|
||||
case currentErr == nil:
|
||||
if !matchConnectionVersion(w, r, current.Version) {
|
||||
return
|
||||
}
|
||||
case errors.Is(currentErr, store.ErrSecurityEventConnectionNotFound):
|
||||
current = ssfreceiver.ConnectionView{}
|
||||
if !matchConnectionVersion(w, r, 0) {
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable")
|
||||
return
|
||||
}
|
||||
auditID, ok := s.requireSecurityEventConnectionAudit(w, r, "connect", traceID, current)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
connection, err := manager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey)
|
||||
@@ -89,13 +107,15 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque
|
||||
s.writeSecurityEventConnectionError(w, r, manager, "connect", traceID, err)
|
||||
return
|
||||
}
|
||||
s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection)
|
||||
s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, auditID, connection)
|
||||
}
|
||||
|
||||
func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := ensureSecurityEventTraceID(w, r)
|
||||
s.securityEventManagementMu.Lock()
|
||||
defer s.securityEventManagementMu.Unlock()
|
||||
manager := s.currentSecurityEventManager()
|
||||
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID)
|
||||
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -104,13 +124,15 @@ func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Re
|
||||
s.writeSecurityEventConnectionError(w, r, manager, "verify", traceID, err)
|
||||
return
|
||||
}
|
||||
s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection)
|
||||
s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, auditID, connection)
|
||||
}
|
||||
|
||||
func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := ensureSecurityEventTraceID(w, r)
|
||||
s.securityEventManagementMu.Lock()
|
||||
defer s.securityEventManagementMu.Unlock()
|
||||
manager := s.currentSecurityEventManager()
|
||||
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID)
|
||||
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -119,13 +141,15 @@ func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter,
|
||||
s.writeSecurityEventConnectionError(w, r, manager, "rotate", traceID, err)
|
||||
return
|
||||
}
|
||||
s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection)
|
||||
s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, auditID, connection)
|
||||
}
|
||||
|
||||
func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := ensureSecurityEventTraceID(w, r)
|
||||
s.securityEventManagementMu.Lock()
|
||||
defer s.securityEventManagementMu.Unlock()
|
||||
manager := s.currentSecurityEventManager()
|
||||
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID)
|
||||
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -134,7 +158,7 @@ func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Re
|
||||
s.writeSecurityEventConnectionError(w, r, manager, "disconnect", traceID, err)
|
||||
return
|
||||
}
|
||||
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection)
|
||||
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, auditID, connection)
|
||||
}
|
||||
|
||||
func (s *Server) securityEventPrerequisites() map[string]any {
|
||||
@@ -164,25 +188,32 @@ func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (s
|
||||
return value, true
|
||||
}
|
||||
|
||||
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, bool) {
|
||||
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, string, bool) {
|
||||
if manager == nil {
|
||||
writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found")
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
requestHash := securityEventOperationHash(operation, normalizedConnectionETag(r.Header.Get("If-Match")))
|
||||
if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) {
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
connection, err := manager.Get(r.Context())
|
||||
if err != nil {
|
||||
s.writeSecurityEventConnectionError(w, r, manager, operation, traceID, err)
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version)
|
||||
if !matchConnectionVersion(w, r, connection.Version) {
|
||||
return "", "", "", false
|
||||
}
|
||||
auditID, ok := s.requireSecurityEventConnectionAudit(w, r, operation, traceID, connection)
|
||||
if !ok {
|
||||
return "", "", "", false
|
||||
}
|
||||
return idempotencyKey, requestHash, auditID, true
|
||||
}
|
||||
|
||||
func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string) bool {
|
||||
@@ -216,8 +247,7 @@ func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Req
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID string, connection ssfreceiver.ConnectionView) {
|
||||
auditID := s.recordSecurityEventConnectionAudit(r, operation, "success", "", traceID, connection)
|
||||
func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID, auditID string, connection ssfreceiver.ConnectionView) {
|
||||
if auditID != "" {
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
}
|
||||
@@ -328,6 +358,16 @@ func (s *Server) recordSecurityEventConnectionAudit(r *http.Request, operation,
|
||||
return audit.ID
|
||||
}
|
||||
|
||||
func (s *Server) requireSecurityEventConnectionAudit(w http.ResponseWriter, r *http.Request, operation, traceID string, connection ssfreceiver.ConnectionView) (string, bool) {
|
||||
auditID := s.recordSecurityEventConnectionAudit(r, operation, "requested", "", traceID, connection)
|
||||
if auditID == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "security event audit is unavailable; operation was not executed", "security_event_audit_unavailable")
|
||||
return "", false
|
||||
}
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
return auditID, true
|
||||
}
|
||||
|
||||
func securityEventConnectionAuditInput(r *http.Request, actor *auth.User, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) store.AuditLogInput {
|
||||
input := store.AuditLogInput{
|
||||
Category: "identity", Action: "identity.security_event_connection." + operation,
|
||||
|
||||
@@ -43,6 +43,16 @@ func TestSecurityEventConnectionWriteHeaders(t *testing.T) {
|
||||
if _, ok := requiredConnectionIdempotencyKey(recorder, request); ok || recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing Idempotency-Key status=%d", recorder.Code)
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodPut, "/connection", nil)
|
||||
recorder = httptest.NewRecorder()
|
||||
if matchConnectionVersion(recorder, request, 0) || recorder.Code != http.StatusPreconditionRequired {
|
||||
t.Fatalf("initial connection without If-Match status=%d", recorder.Code)
|
||||
}
|
||||
request.Header.Set("If-Match", `W/"0"`)
|
||||
recorder = httptest.NewRecorder()
|
||||
if !matchConnectionVersion(recorder, request, 0) {
|
||||
t.Fatalf("initial zero version was rejected: status=%d", recorder.Code)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
|
||||
request.Header.Set("If-Match", `W/"7"`)
|
||||
|
||||
@@ -18,9 +18,13 @@ import "net/http"
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/security-events/ssf [post]
|
||||
func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) {
|
||||
if s.securityEventReceiver == nil {
|
||||
receiver := s.securityEventReceiver
|
||||
if s.identityRuntime != nil {
|
||||
receiver = s.identityRuntime.SecurityEventReceiver()
|
||||
}
|
||||
if receiver == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.securityEventReceiver.ServeHTTP(w, r)
|
||||
receiver.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
||||
)
|
||||
|
||||
type preparedReceiverBuilder struct {
|
||||
receiver http.Handler
|
||||
}
|
||||
|
||||
func (builder *preparedReceiverBuilder) Build(context.Context, identity.Revision) (*identityruntime.Runtime, error) {
|
||||
return &identityruntime.Runtime{}, nil
|
||||
}
|
||||
|
||||
func (builder *preparedReceiverBuilder) PreparedSecurityEventReceiver() http.Handler {
|
||||
return builder.receiver
|
||||
}
|
||||
|
||||
func TestReceiveSecurityEventUsesPreparedReceiverBeforeFirstActivation(t *testing.T) {
|
||||
called := false
|
||||
builder := &preparedReceiverBuilder{receiver: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
})}
|
||||
server := &Server{identityRuntime: identityruntime.NewManager(nil, builder)}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.receiveSecurityEvent(response, request)
|
||||
|
||||
if !called || response.Code != http.StatusAccepted {
|
||||
t.Fatalf("prepared SSF receiver called=%t status=%d", called, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventWriteAuditFailsClosedBeforeMutation(t *testing.T) {
|
||||
server := &Server{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
if _, ok := server.requireSecurityEventConnectionAudit(
|
||||
recorder, request, "verify", "trace-test", securityevents.ConnectionView{},
|
||||
); ok {
|
||||
t.Fatal("security event write unexpectedly continued without durable audit storage")
|
||||
}
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("audit failure status=%d, want 503", recorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ type Server struct {
|
||||
identityRuntime *identityruntime.Manager
|
||||
identityPairing *identity.PairingService
|
||||
identityManagementMu sync.Mutex
|
||||
securityEventManagementMu sync.Mutex
|
||||
identityPairingWorkers sync.Map
|
||||
identityCleanupWorkers sync.Map
|
||||
identityRestoredPairings sync.Map
|
||||
identityTestRevision identity.Revision
|
||||
identityTestCookieSecure bool
|
||||
identityTestBrowserEnabled bool
|
||||
@@ -79,6 +82,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
if err != nil {
|
||||
panic("invalid identity SecretStore: " + err.Error())
|
||||
}
|
||||
go ssfreceiver.RunSecurityEventRetirementWorker(ctx, db)
|
||||
go ssfreceiver.RunIdentitySecretCleanupWorker(ctx, db, secretStore)
|
||||
runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{
|
||||
AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute,
|
||||
HeartbeatInterval: time.Duration(cfg.IdentitySecurityEventHeartbeatIntervalSeconds) * time.Second,
|
||||
@@ -90,15 +95,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
logger.Error("load active identity runtime failed; local management login remains available", "error_category", "identity_runtime_load_failed")
|
||||
}
|
||||
server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) {
|
||||
return identity.NewOnboardingClient(baseURL, nil)
|
||||
}, server.identityRuntime)
|
||||
if pending, pendingErr := db.PendingIdentityPairingExchanges(ctx); pendingErr == nil {
|
||||
for _, pairing := range pending {
|
||||
server.startIdentityPairingWorker(pairing.ID)
|
||||
}
|
||||
} else if logger != nil {
|
||||
logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed")
|
||||
}
|
||||
return identity.NewOnboardingClient(baseURL, nil, cfg.AppEnv)
|
||||
}, server.identityRuntime, cfg.AppEnv)
|
||||
server.reconcileCanonicalIdentityPairing()
|
||||
go server.runIdentityPairingCoordinator()
|
||||
server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier {
|
||||
if runtime := server.identityRuntime.Current(); runtime != nil {
|
||||
return runtime.Verifier
|
||||
@@ -114,8 +114,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
return user, oidcSessionRequestError(resolveErr)
|
||||
}
|
||||
server.auth.LegacyJWTEnabledProvider = func() bool {
|
||||
runtime := server.identityRuntime.Current()
|
||||
return runtime == nil || runtime.Revision.LegacyJWTEnabled
|
||||
return server.identityRuntime.LegacyJWTEnabled()
|
||||
}
|
||||
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
|
||||
server.runner.StartAsyncQueueWorker(ctx)
|
||||
@@ -210,6 +209,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("GET /api/admin/system/identity/configuration", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityConfiguration)))
|
||||
mux.Handle("POST /api/admin/system/identity/pairings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.startIdentityPairing)))
|
||||
mux.Handle("GET /api/admin/system/identity/pairings/{pairingID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityPairing)))
|
||||
mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/cancel", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.cancelIdentityPairing)))
|
||||
mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.retireIdentityPairingSecurityEventConflict)))
|
||||
mux.Handle("PATCH /api/admin/system/identity/revisions/{revisionID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateIdentityDraftPolicy)))
|
||||
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/validate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.validateIdentityRevision)))
|
||||
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/activate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.activateIdentityRevision)))
|
||||
@@ -349,7 +350,7 @@ func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) htt
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && originAllowed(origin, s.cfg.CORSAllowedOrigin) {
|
||||
if origin != "" && s.corsOriginAllowed(origin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
@@ -364,10 +365,21 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) corsOriginAllowed(origin string) bool {
|
||||
if originAllowed(origin, s.cfg.CORSAllowedOrigin) {
|
||||
return true
|
||||
}
|
||||
if s.identityRuntime != nil {
|
||||
return originMatchesBaseURL(origin, s.identityRuntime.TrustedWebBaseURL())
|
||||
}
|
||||
runtime := s.currentIdentityRuntime()
|
||||
return runtime != nil && originMatchesBaseURL(origin, runtime.Revision.WebBaseURL)
|
||||
}
|
||||
|
||||
func originAllowed(origin string, allowed string) bool {
|
||||
for _, item := range strings.Split(allowed, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "*" || strings.EqualFold(origin, item) {
|
||||
if item != "*" && strings.EqualFold(origin, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user