forked from baron/baron-sso
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
This commit is contained in:
@@ -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",
|
||||
|
||||
268
backend/internal/handler/auth_handler_dynamic_claims_test.go
Normal file
268
backend/internal/handler/auth_handler_dynamic_claims_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user