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