package service import ( "baron-sso-backend/internal/domain" "context" "fmt" "log/slog" "net/http" "os" "strings" "time" "github.com/descope/go-sdk/descope" "github.com/descope/go-sdk/descope/client" ) type DescopeProvider struct { Client *client.DescopeClient FrontendURL string fieldMapping map[string]string // Key: Broker Field Name, Value: Descope Attribute Key } func NewDescopeProvider(projectID, managementKey string) *DescopeProvider { var descopeClient *client.DescopeClient var err error if projectID != "" { descopeClient, err = client.NewWithConfig(&client.Config{ ProjectID: projectID, ManagementKey: managementKey, }) if err != nil { slog.Warn("Failed to initialize Descope Client in Provider", "error", err) } } // Define the mapping between BrokerUser fields and Descope attributes. // In a real scenario, this could be loaded from a config file. // For this implementation, we hardcode the support to demonstrate the validation. // We map the Broker's required custom attributes to Descope's keys. mapping := map[string]string{ "grade": "customAttributes.userRank", // Broker 'grade' maps to Descope 'userRank' "department": "customAttributes.dept", // Broker 'department' maps to Descope 'dept' } return &DescopeProvider{ Client: descopeClient, FrontendURL: os.Getenv("USERFRONT_URL"), fieldMapping: mapping, } } func (d *DescopeProvider) Name() string { return "Descope" } // GetMetadata returns the schema support information. // Currently, it returns the standard fields Descope supports + the mapped custom attributes. func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) { // 1. Standard Fields supported by Descope supported := []string{"id", "email", "name", "phone_number"} // 2. Add mapped custom attributes // The Validator checks if the Broker's required keys (e.g., "grade") are present in this list. for brokerKey := range d.fieldMapping { supported = append(supported, brokerKey) } return &domain.IDPMetadata{ SupportedFields: supported, }, nil } // CreateUser는 Descope Management API를 사용해 사용자를 생성합니다. func (d *DescopeProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { if d.Client == nil { return "", fmt.Errorf("descope provider: client is nil") } if user == nil { return "", fmt.Errorf("descope provider: user payload is nil") } if user.Email == "" || password == "" { return "", fmt.Errorf("descope provider: email and password are required") } normalizedPhone := user.PhoneNumber normalizedPhone = strings.ReplaceAll(normalizedPhone, "-", "") normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "") if strings.HasPrefix(normalizedPhone, "010") { normalizedPhone = "+82" + normalizedPhone[1:] } else if strings.HasPrefix(normalizedPhone, "82") { normalizedPhone = "+" + normalizedPhone } // 존재 여부 확인 exists, _ := d.Client.Management.User().Load(context.Background(), user.Email) if exists != nil { return "", fmt.Errorf("descope provider: user already exists") } descopeUser := &descope.UserRequest{} descopeUser.Email = user.Email descopeUser.Phone = normalizedPhone descopeUser.Name = user.Name descopeUser.CustomAttributes = map[string]any{} for k, v := range user.Attributes { descopeUser.CustomAttributes[k] = v } descopeUser.CustomAttributes["createdAt"] = time.Now().Format(time.RFC3339) if _, err := d.Client.Management.User().Create(context.Background(), user.Email, descopeUser); err != nil { return "", fmt.Errorf("descope provider: create user failed: %w", err) } if err := d.Client.Management.User().SetPassword(context.Background(), user.Email, password); err != nil { _ = d.Client.Management.User().Delete(context.Background(), user.Email) return "", fmt.Errorf("descope provider: set password failed: %w", err) } slog.Info("Descope user created", "email", user.Email) return user.Email, nil } // SignIn은 Descope Password 로그인 후 세션 토큰을 반환합니다. func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { if d.Client == nil { return nil, fmt.Errorf("descope provider: client is nil") } authInfo, err := d.Client.Auth.Password().SignIn(context.Background(), loginID, password, nil) if err != nil { return nil, err } res := &domain.AuthInfo{ SessionToken: &domain.Token{ JWT: authInfo.SessionToken.JWT, Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), SessionID: authInfo.SessionToken.ID, }, // 내부 식별자는 Kratos identity ID로 통일합니다. Subject: "", } if authInfo.RefreshToken != nil { res.RefreshToken = &domain.Token{ JWT: authInfo.RefreshToken.JWT, Expiration: time.Unix(authInfo.RefreshToken.Expiration, 0), } } return res, nil } // UserExists는 loginID(이메일/전화번호) 기준으로 사용자가 있는지 확인합니다. func (d *DescopeProvider) UserExists(loginID string) (bool, error) { if d.Client == nil { return false, fmt.Errorf("descope provider: client is nil") } ctx := context.Background() if strings.Contains(loginID, "@") { user, err := d.Client.Management.User().Load(ctx, loginID) if err != nil { if isDescopeNotFound(err) { return false, nil } return false, err } return user != nil, nil } phone := normalizePhone(loginID) searchOptions := &descope.UserSearchOptions{ Phones: []string{phone}, Limit: 1, } users, _, err := d.Client.Management.User().SearchAll(ctx, searchOptions) if err != nil { return false, err } return len(users) > 0, nil } // IssueSession은 비밀번호 없이 로그인 세션을 발급합니다. func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { if d.Client == nil { return nil, fmt.Errorf("descope provider: client is nil") } ctx := context.Background() targetLoginID, err := d.resolveLoginID(loginID) if err != nil { return nil, err } embeddedToken, err := d.Client.Management.User().GenerateEmbeddedLink(ctx, targetLoginID, nil, 0) if err != nil { return nil, fmt.Errorf("descope provider: generate embedded link failed: %w", err) } authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, embeddedToken, nil) if err != nil { return nil, fmt.Errorf("descope provider: magic link verify failed: %w", err) } res := &domain.AuthInfo{ SessionToken: &domain.Token{ JWT: authInfo.SessionToken.JWT, Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), SessionID: authInfo.SessionToken.ID, }, // 내부 식별자는 Kratos identity ID로 통일합니다. Subject: "", } if authInfo.RefreshToken != nil { res.RefreshToken = &domain.Token{ JWT: authInfo.RefreshToken.JWT, Expiration: time.Unix(authInfo.RefreshToken.Expiration, 0), } } return res, nil } func (d *DescopeProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { return nil, domain.ErrNotSupported } func (d *DescopeProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { return nil, domain.ErrNotSupported } // GetPasswordPolicy는 Descope 비밀번호 정책을 반환합니다. func (d *DescopeProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { if d.Client == nil { return nil, fmt.Errorf("descope provider: client is nil") } policy, err := d.Client.Auth.Password().GetPasswordPolicy(context.Background()) if err != nil { return nil, err } return &domain.PasswordPolicy{ MinLength: int(policy.MinLength), Lowercase: policy.Lowercase, Uppercase: policy.Uppercase, Number: policy.Number, NonAlphanumeric: policy.NonAlphanumeric, MinCharacterTypes: 0, }, nil } func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error { ctx := context.Background() err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil) if err != nil { slog.Error("Descope SendPasswordReset failed (raw)", "loginID", loginID, "redirectUrl", redirectUrl, "err", err, "err_type", fmt.Sprintf("%T", err), ) if de, ok := err.(*descope.Error); ok { status := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode] // "Status-Code" slog.Error("Descope error details", "code", de.Code, "description", de.Description, "message", de.Message, "status_code", status, "info", de.Info, ) } } return err } func (d *DescopeProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { ctx := context.Background() authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, token, nil) if err != nil { return nil, err } res := &domain.AuthInfo{ SessionToken: &domain.Token{ JWT: authInfo.SessionToken.JWT, Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), SessionID: authInfo.SessionToken.ID, }, } if authInfo.RefreshToken != nil { res.RefreshToken = &domain.Token{ JWT: authInfo.RefreshToken.JWT, Expiration: time.Unix(authInfo.RefreshToken.Expiration, 0), } } return res, nil } func (d *DescopeProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { ctx := context.Background() return d.Client.Auth.Password().UpdateUserPassword(ctx, loginID, newPassword, r) } func (d *DescopeProvider) resolveLoginID(loginID string) (string, error) { if strings.Contains(loginID, "@") { return loginID, nil } phone := normalizePhone(loginID) searchOptions := &descope.UserSearchOptions{ Phones: []string{phone}, Limit: 1, } users, _, err := d.Client.Management.User().SearchAll(context.Background(), searchOptions) if err != nil { return "", fmt.Errorf("descope provider: user search failed: %w", err) } if len(users) == 0 { return "", fmt.Errorf("descope provider: user not found") } if len(users[0].LoginIDs) > 0 { return users[0].LoginIDs[0], nil } if users[0].UserID != "" { return users[0].UserID, nil } return "", fmt.Errorf("descope provider: user found but login id missing") } func normalizePhone(phone string) string { normalized := strings.ReplaceAll(phone, "-", "") normalized = strings.ReplaceAll(normalized, " ", "") if strings.HasPrefix(normalized, "010") { return "+82" + normalized[1:] } if strings.HasPrefix(normalized, "82") { return "+" + normalized } return normalized } func isDescopeNotFound(err error) bool { if de, ok := err.(*descope.Error); ok { if rawStatus, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok { switch v := rawStatus.(type) { case int: return v == http.StatusNotFound case float64: return int(v) == http.StatusNotFound case string: return v == fmt.Sprintf("%d", http.StatusNotFound) } } } return false }