forked from baron/baron-sso
354 lines
11 KiB
Go
354 lines
11 KiB
Go
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
|
|
}
|