forked from baron/baron-sso
userfront 연동이력 맞춤
This commit is contained in:
@@ -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",
|
||||
|
||||
201
backend/internal/handler/auth_handler_oidc_test.go
Normal file
201
backend/internal/handler/auth_handler_oidc_test.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user