1
0
forked from baron/baron-sso

userfront 연동이력 맞춤

This commit is contained in:
Lectom C Han
2026-02-03 13:37:24 +09:00
parent e20b61189c
commit 4f3d0759c3
24 changed files with 4092 additions and 175 deletions

View File

@@ -0,0 +1,30 @@
package domain
import (
"context"
"time"
)
type OathkeeperAccessLog struct {
Timestamp time.Time
RequestID string
Method string
Path string
Status int
LatencyMs int
RP string
Action string
Target string
Subject string
ClientIP string
UserAgent string
Decision string
TraceID string
SpanID string
Raw string
}
type OathkeeperLogRepository interface {
FindPageBySubject(ctx context.Context, subject string, limit int, cursor *AuditCursor) ([]OathkeeperAccessLog, error)
Ping(ctx context.Context) error
}

View File

@@ -521,6 +521,228 @@ func normalizePhoneForLoginID(phone string) string {
return normalized
}
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[string]any {
claims := map[string]any{}
if traits == nil {
return claims
}
scopeSet := map[string]struct{}{}
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" {
continue
}
scopeSet[scope] = struct{}{}
}
getString := func(key string) string {
raw, ok := traits[key]
if !ok || raw == nil {
return ""
}
switch value := raw.(type) {
case string:
return strings.TrimSpace(value)
default:
return strings.TrimSpace(fmt.Sprint(value))
}
}
displayName := getString("displayname")
if displayName == "" {
displayName = getString("name")
}
if displayName != "" {
claims["name"] = displayName
}
primaryEmail := getString("primary_email")
if primaryEmail == "" {
primaryEmail = getString("email")
}
if primaryEmail != "" {
claims["email"] = primaryEmail
}
if _, ok := scopeSet["profile"]; ok {
profile := map[string]any{}
names := map[string]any{}
for _, key := range []string{
"name",
"displayname",
"preferred_username",
"given_name",
"family_name",
"middle_name",
"nickname",
} {
if value := getString(key); value != "" {
names[key] = value
}
}
if len(names) > 0 {
profile["names"] = names
}
emails := collectEmailList(traits, primaryEmail)
if len(emails) > 0 {
profile["emails"] = emails
}
if len(profile) > 0 {
claims["profile"] = profile
}
for _, key := range []string{
"department",
"affiliationType",
"companyCode",
"displayname",
"team",
"grade",
"familyCompany",
"taxCode",
"familyUniqueKey",
"personal",
} {
if raw, ok := traits[key]; ok && raw != nil {
switch value := raw.(type) {
case string:
if strings.TrimSpace(value) != "" {
claims[key] = strings.TrimSpace(value)
}
default:
claims[key] = value
}
}
}
}
if _, ok := scopeSet["phone"]; ok {
if phone := getString("phone_number"); phone != "" {
claims["phone_number"] = phone
}
}
return claims
}
func collectEmailList(traits map[string]any, primaryEmail string) []string {
emails := make([]string, 0)
seen := make(map[string]struct{})
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
emails = append(emails, value)
}
add(primaryEmail)
for _, key := range []string{"email", "primary_email"} {
if raw, ok := traits[key]; ok {
if value, ok := raw.(string); ok {
add(value)
}
}
}
if raw, ok := traits["emails"]; ok {
switch value := raw.(type) {
case []string:
for _, email := range value {
add(email)
}
case []any:
for _, email := range value {
add(fmt.Sprint(email))
}
}
}
if raw, ok := traits["secondary_emails"]; ok {
switch value := raw.(type) {
case []string:
for _, email := range value {
add(email)
}
case []any:
for _, email := range value {
add(fmt.Sprint(email))
}
}
}
if raw, ok := traits["additional_emails"]; ok {
switch value := raw.(type) {
case []string:
for _, email := range value {
add(email)
}
case []any:
for _, email := range value {
add(fmt.Sprint(email))
}
}
}
return emails
}
func buildIdentityLookupCandidates(loginID string) []string {
seen := make(map[string]struct{})
add := func(value string) {
candidate := strings.TrimSpace(value)
if candidate == "" {
return
}
if _, ok := seen[candidate]; ok {
return
}
seen[candidate] = struct{}{}
}
normalized := strings.TrimSpace(loginID)
add(normalized)
if normalized != "" {
add(strings.ToLower(normalized))
}
if normalized != "" && !strings.Contains(normalized, "@") {
add(normalizePhoneForLoginID(normalized))
}
candidates := make([]string, 0, len(seen))
for candidate := range seen {
candidates = append(candidates, candidate)
}
return candidates
}
func (h *AuthHandler) resolveKratosIdentityID(ctx context.Context, identifiers ...string) (string, error) {
if h.KratosAdmin == nil {
return "", fmt.Errorf("kratos admin unavailable")
}
for _, identifier := range identifiers {
candidate := strings.TrimSpace(identifier)
if candidate == "" {
continue
}
identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(ctx, candidate)
if err == nil && identityID != "" {
return identityID, nil
}
}
return "", fmt.Errorf("kratos identity not found")
}
func (h *AuthHandler) resolveKratosIdentityIDFromLoginID(ctx context.Context, loginID string) (string, error) {
candidates := buildIdentityLookupCandidates(loginID)
return h.resolveKratosIdentityID(ctx, candidates...)
}
func (h *AuthHandler) getSignupState(key string) (*signupState, error) {
val, err := h.RedisService.Get(key)
if err != nil || val == "" {
@@ -1117,6 +1339,12 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
}
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), lookupLoginID)
if resolveErr != nil || subject == "" {
slog.Error("[LoginCode] Failed to resolve kratos identity", "loginID", lookupLoginID, "error", resolveErr)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
}
authInfo.Subject = subject
c.Locals("login_id", lookupLoginID)
setSessionIDLocal(c, authInfo.SessionToken)
@@ -1140,7 +1368,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
"status": "approved",
"pendingRef": pendingRef,
"provider": h.IdpProvider.Name(),
"subject": authInfo.Subject,
"subject": subject,
"message": "Login approved",
})
}
@@ -1149,7 +1377,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
"token": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT,
"provider": h.IdpProvider.Name(),
"subject": authInfo.Subject,
"subject": subject,
"message": "Login successful",
})
}
@@ -1226,6 +1454,12 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
}
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), payload.LoginID)
if resolveErr != nil || subject == "" {
slog.Error("[LoginShortCode] Failed to resolve kratos identity", "loginID", payload.LoginID, "error", resolveErr)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
}
authInfo.Subject = subject
c.Locals("login_id", payload.LoginID)
setSessionIDLocal(c, authInfo.SessionToken)
@@ -1247,7 +1481,7 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
"token": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT,
"provider": h.IdpProvider.Name(),
"subject": authInfo.Subject,
"subject": subject,
"message": "Login approved",
})
}
@@ -1256,7 +1490,7 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
"token": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT,
"provider": h.IdpProvider.Name(),
"subject": authInfo.Subject,
"subject": subject,
"message": "Login successful",
})
}
@@ -1311,6 +1545,13 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID)
if resolveErr != nil || subject == "" {
slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve user identity")
}
authInfo.Subject = subject
ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime)
ale.SessionJwt = authInfo.SessionToken.JWT
@@ -1320,7 +1561,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
// --- OIDC 로그인 흐름 처리 ---
if req.LoginChallenge != "" {
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge)
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, authInfo.Subject)
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
if err != nil {
slog.Error("failed to accept hydra login request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
@@ -2326,12 +2567,21 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"})
}
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr != nil || identityID == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
}
dept, _ := userResponse.CustomAttributes["department"].(string)
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
resp := domain.UserProfileResponse{
ID: userResponse.UserID,
ID: identityID,
Email: userResponse.Email,
Name: userResponse.Name,
Phone: h.formatPhoneForDisplay(userResponse.Phone),
@@ -3033,24 +3283,43 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "hydra admin unavailable"})
}
subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" {
subjects, err := h.resolveConsentSubjects(c)
if err != nil || len(subjects) == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
}
sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, "")
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
var sessions []service.HydraConsentSession
var lastErr error
hasSuccess := false
for _, subject := range subjects {
subject = strings.TrimSpace(subject)
if subject == "" {
continue
}
linked, listErr := h.Hydra.ListConsentSessions(c.Context(), subject, "")
if listErr != nil {
lastErr = listErr
continue
}
hasSuccess = true
sessions = append(sessions, linked...)
}
if !hasSuccess && lastErr != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": lastErr.Error()})
}
records := make(map[string]*linkedRpRecord)
for _, session := range sessions {
clientID := strings.TrimSpace(session.Client.ClientID)
client := session.Client
if client.ClientID == "" && session.ConsentRequest != nil {
client = session.ConsentRequest.Client
}
clientID := strings.TrimSpace(client.ClientID)
if clientID == "" {
continue
}
name := strings.TrimSpace(session.Client.ClientName)
name := strings.TrimSpace(client.ClientName)
if name == "" {
name = clientID
}
@@ -3060,11 +3329,13 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
lastAuth = *session.AuthenticatedAt
} else if session.RequestedAt != nil {
lastAuth = *session.RequestedAt
} else if session.HandledAt != nil {
lastAuth = *session.HandledAt
}
scopes := session.GrantedScope
if len(scopes) == 0 && strings.TrimSpace(session.Client.Scope) != "" {
scopes = strings.Fields(session.Client.Scope)
if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
scopes = strings.Fields(client.Scope)
}
existing := records[clientID]
@@ -3073,8 +3344,8 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
linkedRpSummary: linkedRpSummary{
ID: clientID,
Name: name,
Logo: extractHydraClientLogo(session.Client.Metadata),
Status: hydraClientStatus(session.Client.Metadata),
Logo: extractHydraClientLogo(client.Metadata),
Status: hydraClientStatus(client.Metadata),
Scopes: scopes,
},
lastAuth: lastAuth,
@@ -3086,7 +3357,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
existing.Name = name
}
if existing.Logo == "" {
existing.Logo = extractHydraClientLogo(session.Client.Metadata)
existing.Logo = extractHydraClientLogo(client.Metadata)
}
existing.Scopes = mergeScopes(existing.Scopes, scopes)
if lastAuth.After(existing.lastAuth) {
@@ -3145,7 +3416,19 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
}
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest)
if consentRequest.Subject == "" {
return fiber.NewError(fiber.StatusInternalServerError, "Consent subject missing")
}
if h.KratosAdmin == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Kratos admin unavailable")
}
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
if err != nil || identity == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load identity")
}
sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims)
if err != nil {
slog.Error("failed to accept hydra consent request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request")
@@ -3154,6 +3437,30 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
return c.JSON(acceptResp)
}
func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
var req struct {
LoginChallenge string `json:"login_challenge"`
}
if err := c.BodyParser(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if req.LoginChallenge == "" {
return fiber.NewError(fiber.StatusBadRequest, "login_challenge is required")
}
subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
}
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
if err != nil {
slog.Error("failed to accept hydra login request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
}
return c.JSON(acceptResp)
}
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
token := h.getBearerToken(c)
@@ -3165,11 +3472,19 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
if err != nil {
return nil, err
}
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr != nil || identityID == "" {
return nil, fmt.Errorf("failed to resolve kratos identity for profile")
}
dept, _ := userResponse.CustomAttributes["department"].(string)
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
return &domain.UserProfileResponse{
ID: userResponse.UserID,
ID: identityID,
Email: userResponse.Email,
Name: userResponse.Name,
Phone: h.formatPhoneForDisplay(userResponse.Phone),
@@ -3197,27 +3512,34 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
token := h.getBearerToken(c)
if token != "" {
if looksLikeJWT(token) && h.DescopeClient != nil && h.KratosAdmin != nil {
if looksLikeJWT(token) && h.DescopeClient != nil {
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
if err == nil && authorized {
userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
if loadErr == nil {
if email := strings.TrimSpace(userResponse.Email); email != "" {
if identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email); err == nil && identityID != "" {
return identityID, nil
}
}
if phone := strings.TrimSpace(userResponse.Phone); phone != "" {
normalized := normalizePhoneForLoginID(phone)
if identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), normalized); err == nil && identityID != "" {
return identityID, nil
}
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr == nil {
return identityID, nil
}
}
return userToken.ID, nil
return "", fmt.Errorf("failed to resolve kratos identity for consent subject")
}
}
return h.resolveIdentityID(c, token)
identityID, resolveErr := h.resolveIdentityID(c, token)
if resolveErr == nil && identityID != "" {
return identityID, nil
}
if cookie := c.Get("Cookie"); cookie != "" {
cookieID, _, cookieErr := h.getKratosIdentityWithCookie(cookie)
if cookieErr == nil && cookieID != "" {
return cookieID, nil
}
}
return "", resolveErr
}
cookie := c.Get("Cookie")
if cookie == "" {
@@ -3227,6 +3549,114 @@ func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
return identityID, err
}
func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) {
token := h.getBearerToken(c)
if token != "" && looksLikeJWT(token) && h.DescopeClient != nil {
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
if err == nil && authorized {
subjects := make([]string, 0, 2)
userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
if loadErr == nil {
subjects = appendLoginIDsFromValues(subjects, userResponse.Email, userResponse.Phone)
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr == nil && identityID != "" {
subjects = append([]string{identityID}, subjects...)
}
}
return uniqueStrings(subjects), nil
}
}
if token != "" {
identityID, traits, err := h.getKratosIdentity(token)
if err == nil && identityID != "" {
subjects := []string{identityID}
subjects = appendLoginIDsFromTraits(subjects, traits)
return uniqueStrings(subjects), nil
}
}
cookie := c.Get("Cookie")
if cookie == "" {
return nil, fmt.Errorf("missing authorization token")
}
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
if err != nil {
return nil, err
}
subjects := []string{identityID}
subjects = appendLoginIDsFromTraits(subjects, traits)
return uniqueStrings(subjects), nil
}
func uniqueStrings(items []string) []string {
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return result
}
func appendLoginIDsFromValues(subjects []string, email string, phone string) []string {
if strings.TrimSpace(email) != "" {
subjects = append(subjects, strings.TrimSpace(email))
}
if strings.TrimSpace(phone) != "" {
subjects = append(subjects, normalizePhoneForLoginID(phone))
}
return subjects
}
func appendLoginIDsFromTraits(subjects []string, traits map[string]interface{}) []string {
if traits == nil {
return subjects
}
if raw, ok := traits["email"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, strings.TrimSpace(value))
}
}
if raw, ok := traits["phone"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
if raw, ok := traits["phone_number"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
if raw, ok := traits["phoneNumber"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
if raw, ok := traits["mobile"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
if raw, ok := traits["mobile_number"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
return subjects
}
func isAuthEventType(eventType string) bool {
normalized := strings.ToLower(eventType)
return strings.Contains(normalized, " /api/v1/auth/")
@@ -3662,7 +4092,22 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err
if looksLikeJWT(token) && h.DescopeClient != nil {
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
if err == nil && authorized {
return userToken.ID, nil
if h.KratosAdmin == nil {
return "", fmt.Errorf("kratos admin unavailable")
}
userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
if loadErr != nil {
return "", loadErr
}
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr != nil || identityID == "" {
return "", fmt.Errorf("failed to resolve kratos identity for token")
}
return identityID, nil
}
}
id, _, err := h.getKratosIdentity(token)
@@ -3688,7 +4133,7 @@ func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (*dom
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return nil, "", "", fmt.Errorf("descope issue session returned empty token")
}
return authInfo.SessionToken, loginID, userToken.ID, nil
return authInfo.SessionToken, loginID, "", nil
}
func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
@@ -4204,16 +4649,24 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"})
}
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
currentUser.Email,
normalizePhoneForLoginID(currentUser.Phone),
)
if resolveErr != nil || identityID == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
}
newPhoneStorage := h.formatPhoneForStorage(req.Phone)
oldPhoneStorage := currentUser.Phone
slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name)
slog.Info("[UpdateMe] Checking changes", "userID", identityID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name)
// 2. Handle Phone Number Change
if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage {
// Check verification status in Redis
verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage
verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage
val, _ := h.RedisService.Get(verifyKey)
if val != "verified" {
slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey)
@@ -4221,7 +4674,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
}
// Update Phone in Descope and mark as verified
slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage)
slog.Info("[UpdateMe] Updating phone number", "userID", identityID, "newPhone", newPhoneStorage)
_, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false)
if err != nil {
slog.Error("Failed to update phone in Descope", "error", err)
@@ -4250,7 +4703,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
// 3. Update Name if changed
if req.Name != "" && req.Name != currentUser.Name {
slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name)
slog.Info("[UpdateMe] Updating display name", "userID", identityID, "newName", req.Name)
_, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name)
if err != nil {
slog.Error("Failed to update user name", "error", err)
@@ -4260,13 +4713,13 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
// 4. Update Custom Attributes (Department)
if req.Department != "" {
slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department)
slog.Info("[UpdateMe] Updating department", "userID", identityID, "dept", req.Department)
if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil {
slog.Error("Failed to update department", "error", err)
}
}
slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID)
slog.Info("[UpdateMe] Profile update completed successfully", "userID", identityID)
return c.JSON(fiber.Map{
"status": "success",

View File

@@ -0,0 +1,201 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"baron-sso-backend/internal/service"
)
func newOidcLoginTestApp(h *AuthHandler) *fiber.App {
app := fiber.New()
app.Post("/api/v1/auth/oidc/login/accept", h.AcceptOidcLoginRequest)
return app
}
func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
var gotSubject string
var gotChallenge string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path != "/sessions/whoami" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
if r.Header.Get("X-Session-Token") != "" {
return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil
}
if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
}
return httpJSON(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "kratos-123",
"traits": map[string]interface{}{},
},
}), nil
case "hydra.test":
if r.URL.Path != "/oauth2/auth/requests/login/accept" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
gotChallenge = r.URL.Query().Get("login_challenge")
body, _ := io.ReadAll(r.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
if subject, ok := payload["subject"].(string); ok {
gotSubject = subject
}
return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil
default:
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
app := newOidcLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"login_challenge": "challenge-123",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Cookie", "ory_kratos_session=abc123")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got["redirectTo"] != "http://rp/cb" {
t.Fatalf("unexpected redirectTo: %v", got["redirectTo"])
}
if gotSubject != "kratos-123" {
t.Fatalf("unexpected subject: %v", gotSubject)
}
if gotChallenge != "challenge-123" {
t.Fatalf("unexpected login_challenge: %v", gotChallenge)
}
}
func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
var gotSubject string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path != "/sessions/whoami" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
if r.Header.Get("X-Session-Token") != "" {
return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil
}
if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
}
return httpJSON(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "kratos-456",
"traits": map[string]interface{}{},
},
}), nil
case "hydra.test":
if r.URL.Path != "/oauth2/auth/requests/login/accept" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
body, _ := io.ReadAll(r.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
if subject, ok := payload["subject"].(string); ok {
gotSubject = subject
}
return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil
default:
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
app := newOidcLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"login_challenge": "challenge-456",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer invalid-token")
req.Header.Set("Cookie", "ory_kratos_session=def456")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if gotSubject != "kratos-456" {
t.Fatalf("unexpected subject: %v", gotSubject)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func httpResponse(req *http.Request, status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: req,
}
}
func httpJSON(req *http.Request, status int, payload map[string]interface{}) *http.Response {
data, _ := json.Marshal(payload)
resp := httpResponse(req, status, string(data))
resp.Header.Set("Content-Type", "application/json")
return resp
}

View File

@@ -396,16 +396,26 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
items := make([]consentSummary, 0, len(sessions))
for _, session := range sessions {
client := session.Client
if client.ClientID == "" && session.ConsentRequest != nil {
client = session.ConsentRequest.Client
}
subject := session.Subject
if subject == "" && session.ConsentRequest != nil {
subject = session.ConsentRequest.Subject
}
authAt := ""
if session.AuthenticatedAt != nil {
authAt = session.AuthenticatedAt.Format(time.RFC3339)
} else if session.RequestedAt != nil {
authAt = session.RequestedAt.Format(time.RFC3339)
} else if session.HandledAt != nil {
authAt = session.HandledAt.Format(time.RFC3339)
}
items = append(items, consentSummary{
Subject: session.Subject,
ClientID: session.Client.ClientID,
ClientName: session.Client.ClientName,
Subject: subject,
ClientID: client.ClientID,
ClientName: client.ClientName,
GrantedScopes: session.GrantedScope,
AuthenticatedAt: authAt,
})

View File

@@ -0,0 +1,106 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"fmt"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)
type OathkeeperClickHouseRepository struct {
conn driver.Conn
}
func NewOathkeeperClickHouseRepository(host string, port int, user, password, db string) (*OathkeeperClickHouseRepository, error) {
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{fmt.Sprintf("%s:%d", host, port)},
Auth: clickhouse.Auth{
Database: db,
Username: user,
Password: password,
},
Debug: false,
})
if err != nil {
return nil, fmt.Errorf("failed to open ory clickhouse connection: %w", err)
}
if err := conn.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("failed to ping ory clickhouse: %w", err)
}
return &OathkeeperClickHouseRepository{conn: conn}, nil
}
func (r *OathkeeperClickHouseRepository) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) {
if limit <= 0 {
limit = 50
}
query := `
SELECT timestamp, request_id, method, path, status, latency_ms, rp, action, target, subject, client_ip, user_agent, decision, trace_id, span_id, raw
FROM oathkeeper_access_logs
`
args := make([]any, 0, 5)
if subject != "" {
query += `
WHERE subject = ?
`
args = append(args, subject)
if cursor != nil {
query += `
AND ((timestamp < ?) OR (timestamp = ? AND request_id < ?))
`
args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID)
}
} else if cursor != nil {
query += `
WHERE (timestamp < ?) OR (timestamp = ? AND request_id < ?)
`
args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID)
}
query += `
ORDER BY timestamp DESC, request_id DESC
LIMIT ?
`
args = append(args, limit)
rows, err := r.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query oathkeeper logs: %w", err)
}
defer rows.Close()
var logs []domain.OathkeeperAccessLog
for rows.Next() {
var log domain.OathkeeperAccessLog
if err := rows.Scan(
&log.Timestamp,
&log.RequestID,
&log.Method,
&log.Path,
&log.Status,
&log.LatencyMs,
&log.RP,
&log.Action,
&log.Target,
&log.Subject,
&log.ClientIP,
&log.UserAgent,
&log.Decision,
&log.TraceID,
&log.SpanID,
&log.Raw,
); err != nil {
return nil, fmt.Errorf("failed to scan oathkeeper log: %w", err)
}
logs = append(logs, log)
}
return logs, nil
}
func (r *OathkeeperClickHouseRepository) Ping(ctx context.Context) error {
if r == nil || r.conn == nil {
return fmt.Errorf("ory clickhouse connection is nil")
}
return r.conn.Ping(ctx)
}

View File

@@ -135,7 +135,8 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
SessionID: authInfo.SessionToken.ID,
},
Subject: authInfo.User.UserID,
// 내부 식별자는 Kratos identity ID로 통일합니다.
Subject: "",
}
if authInfo.RefreshToken != nil {
res.RefreshToken = &domain.Token{
@@ -204,7 +205,8 @@ func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error)
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
SessionID: authInfo.SessionToken.ID,
},
Subject: authInfo.User.UserID,
// 내부 식별자는 Kratos identity ID로 통일합니다.
Subject: "",
}
if authInfo.RefreshToken != nil {
res.RefreshToken = &domain.Token{

View File

@@ -46,13 +46,17 @@ type HydraConsentRequest struct {
}
type HydraConsentSession struct {
Subject string `json:"subject"`
GrantedScope []string `json:"granted_scope"`
GrantedAudience []string `json:"granted_audience,omitempty"`
Remember bool `json:"remember"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
Client HydraClient `json:"client"`
ConsentRequestID string `json:"consent_request_id,omitempty"`
Subject string `json:"subject,omitempty"`
GrantedScope []string `json:"grant_scope,omitempty"`
GrantedAudience []string `json:"grant_access_token_audience,omitempty"`
Remember bool `json:"remember"`
RememberFor int `json:"remember_for,omitempty"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
HandledAt *time.Time `json:"handled_at,omitempty"`
Client HydraClient `json:"client,omitempty"`
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
}
func NewHydraAdminService() *HydraAdminService {
@@ -267,13 +271,13 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body))
}
var sessions []HydraConsentSession
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
if err := json.Unmarshal(body, &sessions); err != nil {
return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err)
}
return sessions, nil
@@ -398,7 +402,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str
return &consentReq, nil
}
func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest) (*AcceptConsentRequestResponse, error) {
func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest, sessionClaims map[string]any) (*AcceptConsentRequestResponse, error) {
params := map[string]string{
"consent_challenge": challenge,
}
@@ -413,6 +417,12 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
"remember": true,
"remember_for": 3600,
}
if len(sessionClaims) > 0 {
payload["session"] = map[string]any{
"id_token": sessionClaims,
"access_token": sessionClaims,
}
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body))
@@ -443,7 +453,6 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
return &AcceptConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
}
func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge string, subject string) (*AcceptLoginRequestResponse, error) {
params := map[string]string{
"login_challenge": challenge,