From cfba44cec2790ccb11b02501c448ece71f106cae Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 23 Apr 2026 17:59:21 +0900 Subject: [PATCH 1/3] feat: support dynamic multi-tenant OIDC claims injection (#609) - Inject claim based on OIDC Client metadata - Extract namespaced tenant metadata from traits and flatten it to root - Expose all joined tenants metadata under and arrays - Fix missing AuditLog generation during auto-accepted Consent - Associate correct during auth events AuditLog recording - Add unit and integration tests for dynamic claims --- backend/internal/handler/auth_handler.go | 119 +++++++- .../auth_handler_dynamic_claims_test.go | 268 ++++++++++++++++++ 2 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 backend/internal/handler/auth_handler_dynamic_claims_test.go diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3c3ea26f..4bb4fd33 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -990,7 +990,7 @@ func normalizePhoneForLoginID(phone string) string { return normalized } -func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[string]any { +func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any { claims := map[string]any{} if traits == nil { return claims @@ -1092,9 +1092,58 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[strin } } + // [New] Dynamic Claim Injection for Multi-tenancy + if tenantID != "" { + claims["tenant_id"] = tenantID + // Extract namespaced metadata if available + // The key in traits is expected to be the tenantID + if namespaced, ok := traits[tenantID].(map[string]any); ok { + for k, v := range namespaced { + claims[k] = v + } + } else if namespaced, ok := traits[tenantID].(map[string]interface{}); ok { + for k, v := range namespaced { + claims[k] = v + } + } + } + + // [Update] Pass ALL tenants the user belongs to + allTenants := map[string]any{} + var joinedTenants []string + + // Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant + for k, v := range traits { + if m, ok := v.(map[string]any); ok { + allTenants[k] = m + joinedTenants = append(joinedTenants, k) + } else if m, ok := v.(map[string]interface{}); ok { + allTenants[k] = m + joinedTenants = append(joinedTenants, k) + } + } + + // [Fix] Include primary tenant_id in joined_tenants if it's not already there + if primaryTenantID := getString("tenant_id"); primaryTenantID != "" { + found := false + for _, id := range joinedTenants { + if id == primaryTenantID { + found = true + break + } + } + if !found { + joinedTenants = append(joinedTenants, primaryTenantID) + } + } + + if len(allTenants) > 0 || len(joinedTenants) > 0 { + claims["tenants"] = allTenants + claims["joined_tenants"] = joinedTenants + } + return claims } - func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any { if claims == nil { claims = map[string]any{} @@ -2957,6 +3006,12 @@ func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) { clientName = clientID } + if client.Metadata != nil { + if tid, ok := client.Metadata["tenant_id"].(string); ok && tid != "" { + c.Locals("tenant_id", tid) + } + } + c.Locals("audit_details_extra", map[string]any{ "client_id": clientID, "client_name": clientName, @@ -5081,10 +5136,23 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject) // 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행 } else { + var tenantID string + if consentRequest.Client.Metadata != nil { + if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { + tenantID = tid + } + } + sessionClaims := withOidcSessionMetadata( - buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID), h.resolveCurrentSessionID(c), ) + + // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시) + if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { + slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS (SKIP) ===", "claims", string(debugClaimsJSON)) + } + acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to auto-accept hydra consent request", "error", err) @@ -5099,6 +5167,35 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { } _ = h.ConsentRepo.Upsert(c.Context(), consent) } + + if h.AuditRepo != nil { + detailsMap := map[string]interface{}{ + "client_id": consentRequest.Client.ClientID, + "scopes": consentRequest.RequestedScope, + "client_name": consentRequest.Client.ClientName, + } + currentSessionID := h.resolveCurrentSessionID(c) + if currentSessionID != "" { + detailsMap["session_id"] = currentSessionID + detailsMap["approved_session_id"] = currentSessionID + } + detailsMap["auto_accepted"] = true + detailsBytes, _ := json.Marshal(detailsMap) + + _ = h.AuditRepo.Create(&domain.AuditLog{ + EventID: GenerateSecureToken(16), + Timestamp: time.Now(), + UserID: consentRequest.Subject, + TenantID: tenantID, // Uses the tenantID extracted earlier + SessionID: currentSessionID, + EventType: "consent.granted", + Status: "success", + IPAddress: c.IP(), + UserAgent: string(c.Request().Header.UserAgent()), + Details: string(detailsBytes), + }) + } + slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID) return c.JSON(acceptResp) } @@ -5205,11 +5302,24 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { c.Locals("login_id", loginID) } currentSessionID := h.resolveCurrentSessionID(c) + + var tenantID string + if consentRequest.Client.Metadata != nil { + if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { + tenantID = tid + } + } + sessionClaims := withOidcSessionMetadata( - buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID), currentSessionID, ) + // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용) + if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { + slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS ===", "claims", string(debugClaimsJSON)) + } + acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to accept hydra consent request", "error", err) @@ -5245,6 +5355,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, + TenantID: tenantID, // [New] Add TenantID to AuditLog SessionID: currentSessionID, EventType: "consent.granted", Status: "success", diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go new file mode 100644 index 00000000..9628d54f --- /dev/null +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -0,0 +1,268 @@ +package handler + +import ( + "baron-sso-backend/internal/service" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { + traits := map[string]interface{}{ + "email": "user@baron.com", + "name": "홍길동", + "tenant_id": "primary-tenant-999", // Added primary tenant + "tenant-1": map[string]interface{}{ + "department": "개발팀", + "grade": "선임", + }, + "tenant-2": map[string]interface{}{ + "department": "재무팀", + "grade": "팀장", + }, + } + scopes := []string{"openid", "profile"} + + t.Run("No tenantID", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, scopes, "") + assert.Equal(t, "user@baron.com", claims["email"]) + assert.Equal(t, "홍길동", claims["name"]) + assert.Nil(t, claims["department"]) + assert.Nil(t, claims["grade"]) + + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "tenant-1") + assert.Contains(t, claims["joined_tenants"], "tenant-2") + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") // Should contain primary + }) + + t.Run("With tenant-1", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1") + assert.Equal(t, "user@baron.com", claims["email"]) + assert.Equal(t, "홍길동", claims["name"]) + assert.Equal(t, "tenant-1", claims["tenant_id"]) // Dynamic tenant injection overwrites top-level for this context + assert.Equal(t, "개발팀", claims["department"]) + assert.Equal(t, "선임", claims["grade"]) + + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "tenant-1") + assert.Contains(t, claims["joined_tenants"], "tenant-2") + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") + }) + + t.Run("With tenant-2", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2") + assert.Equal(t, "user@baron.com", claims["email"]) + assert.Equal(t, "홍길동", claims["name"]) + assert.Equal(t, "tenant-2", claims["tenant_id"]) + assert.Equal(t, "재무팀", claims["department"]) + assert.Equal(t, "팀장", claims["grade"]) + + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") + }) + + t.Run("With non-existent tenant", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3") + assert.Equal(t, "user@baron.com", claims["email"]) + assert.Equal(t, "홍길동", claims["name"]) + assert.Equal(t, "tenant-3", claims["tenant_id"]) + assert.Nil(t, claims["department"]) + assert.Nil(t, claims["grade"]) + + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "tenant-1") + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") + }) +} + +func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { + var capturedClaims map[string]interface{} + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // Hydra: Get Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-dynamic", + "requested_scope": []string{"openid", "profile"}, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + "metadata": map[string]interface{}{ + "tenant_id": "tenant-abc", + }, + }, + }), nil + } + // Kratos: Get Identity + if r.URL.Path == "/admin/identities/user-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@test.com", + "name": "Test User", + "tenant-abc": map[string]interface{}{ + "department": "Innovation", + "position": "Architect", + }, + }, + }), nil + } + // Hydra: Accept Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" { + // Capture the claims sent to Hydra + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]interface{} + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]interface{}); ok { + capturedClaims = session["id_token"].(map[string]interface{}) + } + + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ + ID: "user-123", + Traits: map[string]interface{}{ + "email": "user@test.com", + "name": "Test User", + "tenant-abc": map[string]interface{}{ + "department": "Innovation", + "position": "Architect", + }, + }, + }, nil) + + app := fiber.New() + app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "consent_challenge": "challenge-dynamic", + "grant_scope": []string{"openid", "profile"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify captured claims + assert.NotNil(t, capturedClaims) + assert.Equal(t, "user@test.com", capturedClaims["email"]) + assert.Equal(t, "tenant-abc", capturedClaims["tenant_id"]) + assert.Equal(t, "Innovation", capturedClaims["department"]) + assert.Equal(t, "Architect", capturedClaims["position"]) +} + +func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { + var capturedClaims map[string]interface{} + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // Hydra: Get Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-skip-dynamic", + "requested_scope": []string{"openid", "profile"}, + "skip": true, + "subject": "user-456", + "client": map[string]interface{}{ + "client_id": "skip-app", + "metadata": map[string]interface{}{ + "tenant_id": "tenant-xyz", + }, + }, + }), nil + } + // Kratos: Get Identity + if r.URL.Path == "/admin/identities/user-456" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "id": "user-456", + "traits": map[string]interface{}{ + "email": "skip@test.com", + "tenant-xyz": map[string]interface{}{ + "department": "Security", + "position": "Officer", + }, + }, + }), nil + } + // Hydra: Accept Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" { + // Capture the claims sent to Hydra + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]interface{} + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]interface{}); ok { + capturedClaims = session["id_token"].(map[string]interface{}) + } + + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-456").Return(&service.KratosIdentity{ + ID: "user-456", + Traits: map[string]interface{}{ + "email": "skip@test.com", + "tenant-xyz": map[string]interface{}{ + "department": "Security", + "position": "Officer", + }, + }, + }, nil) + + app := fiber.New() + app.Get("/api/v1/auth/consent", h.GetConsentRequest) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip-dynamic", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify captured claims + assert.NotNil(t, capturedClaims) + assert.Equal(t, "skip@test.com", capturedClaims["email"]) + assert.Equal(t, "tenant-xyz", capturedClaims["tenant_id"]) + assert.Equal(t, "Security", capturedClaims["department"]) + assert.Equal(t, "Officer", capturedClaims["position"]) +} + From 8e28a9d74b68553aa12889c7a53c86209472ab48 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 24 Apr 2026 11:59:49 +0900 Subject: [PATCH 2/3] fix(infra): resolve CORS error and Nginx 502 Bad Gateway - Update Hydra and Kratos CORS config to specify allowed origins explicitly instead of using wildcard with allow_credentials: true - Fix Nginx upstream resolution for Oathkeeper to use correct container hostname (ory_oathkeeper) --- docker/ory/hydra/hydra.yml | 20 ++++++++++++++++++-- docker/ory/kratos/kratos.yml | 9 +++++++++ docker/ory/oathkeeper/rules.active.json | 2 +- gateway/nginx.conf | 2 +- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docker/ory/hydra/hydra.yml b/docker/ory/hydra/hydra.yml index 7bf79b10..6891693b 100644 --- a/docker/ory/hydra/hydra.yml +++ b/docker/ory/hydra/hydra.yml @@ -7,7 +7,15 @@ serve: cors: enabled: true allowed_origins: - - "*" + - http://localhost:5173 + - http://localhost:5174 + - http://localhost:5175 + - http://localhost:5000 + - https://ssologin.hmac.kr + - https://sso-test.hmac.kr + - https://app.brsw.kr + - https://sss.hmac.kr + - https://sso.hmac.kr allowed_methods: - POST - GET @@ -37,7 +45,15 @@ serve: cors: enabled: true allowed_origins: - - "*" + - http://localhost:5173 + - http://localhost:5174 + - http://localhost:5175 + - http://localhost:5000 + - https://ssologin.hmac.kr + - https://sso-test.hmac.kr + - https://app.brsw.kr + - https://sss.hmac.kr + - https://sso.hmac.kr allowed_methods: - POST - GET diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index 043a04c4..583dd96e 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -8,8 +8,17 @@ serve: cors: enabled: true allowed_origins: + - http://localhost:5173 + - http://localhost:5174 + - http://localhost:5175 + - http://localhost:5000 - http://backend:3000 - http://baron_backend:3000 + - https://ssologin.hmac.kr + - https://sso-test.hmac.kr + - https://app.brsw.kr + - https://sss.hmac.kr + - https://sso.hmac.kr admin: base_url: http://localhost:4434/ diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json index 4a0735da..fd6bfb2d 100755 --- a/docker/ory/oathkeeper/rules.active.json +++ b/docker/ory/oathkeeper/rules.active.json @@ -156,4 +156,4 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] } -] \ No newline at end of file +] diff --git a/gateway/nginx.conf b/gateway/nginx.conf index 8101d670..0815f891 100644 --- a/gateway/nginx.conf +++ b/gateway/nginx.conf @@ -31,7 +31,7 @@ server { resolver 127.0.0.11 valid=10s ipv6=off; set $backend_upstream http://baron_backend:3000; set $userfront_upstream http://baron_userfront:5000; - set $oathkeeper_upstream http://oathkeeper:4455; + set $oathkeeper_upstream http://ory_oathkeeper:4455; error_log /dev/stderr warn; access_log /var/log/nginx/access.log json_combined; From f810427b213ff3a0fb6853a3ca17b78fcc635822 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 24 Apr 2026 12:00:00 +0900 Subject: [PATCH 3/3] chore(auth): restrict OIDC generated claims debug logs to dev environment - Prevent overly verbose logging of ID token payloads in production by checking APP_ENV --- backend/internal/handler/auth_handler.go | 14 ++++++++++---- backend/internal/handler/user_handler.go | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 4bb4fd33..5d2d4dca 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -5149,8 +5149,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { ) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시) - if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { - slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS (SKIP) ===", "claims", string(debugClaimsJSON)) + appEnv := strings.ToLower(os.Getenv("APP_ENV")) + if appEnv == "dev" || appEnv == "development" || appEnv == "" { + if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { + slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS (SKIP) ===", "claims", string(debugClaimsJSON)) + } } acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) @@ -5316,8 +5319,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { ) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용) - if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { - slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS ===", "claims", string(debugClaimsJSON)) + appEnv := strings.ToLower(os.Getenv("APP_ENV")) + if appEnv == "dev" || appEnv == "development" || appEnv == "" { + if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { + slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS ===", "claims", string(debugClaimsJSON)) + } } acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 4c0f0757..a56a0564 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -323,7 +323,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { } return c.JSON(h.mapIdentitySummary(c.Context(), *identity)) -} +} func (h *UserHandler) CreateUser(c *fiber.Ctx) error { if h.OryProvider == nil || h.KratosAdmin == nil {