1
0
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:
2026-04-23 17:59:21 +09:00
parent 991577258b
commit cfba44cec2
2 changed files with 383 additions and 4 deletions

View File

@@ -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",

View 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"])
}