1
0
forked from baron/baron-sso

ory-hosting 기본구동

This commit is contained in:
Lectom C Han
2026-01-27 22:58:49 +09:00
parent 41f0549435
commit c3f7b18afc
31 changed files with 1910 additions and 176 deletions

View File

@@ -0,0 +1,81 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"errors"
"fmt"
"log/slog"
"os"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// Run executes the application bootstrap logic (migrations, seeding, etc.)
func Run(db *gorm.DB) error {
slog.Info("[Bootstrap] Starting application bootstrap...")
// 1. Auto Migration
if err := migrateSchemas(db); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
// 2. Seed Initial Admin User
if err := seedAdminUser(db); err != nil {
return fmt.Errorf("seeding admin failed: %w", err)
}
slog.Info("[Bootstrap] Bootstrap completed successfully.")
return nil
}
func migrateSchemas(db *gorm.DB) error {
slog.Info("[Bootstrap] Migrating database schemas...")
// Add all domain models here
return db.AutoMigrate(
&domain.User{},
// &domain.RelyingParty{}, // TODO: Uncomment when model is ready
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
)
}
func seedAdminUser(db *gorm.DB) error {
adminEmail := os.Getenv("ADMIN_EMAIL")
adminPassword := os.Getenv("ADMIN_PASSWORD")
if adminEmail == "" || adminPassword == "" {
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin seeding.")
return nil
}
var user domain.User
if err := db.Where("email = ?", adminEmail).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
adminUser := domain.User{
Email: adminEmail,
PasswordHash: string(hashedPassword),
Name: "System Admin",
Role: "admin", // Assuming 'role' field exists or handling via attributes
// Add other required fields
}
if err := db.Create(&adminUser).Error; err != nil {
return err
}
slog.Info("[Bootstrap] Admin user created successfully.")
} else {
return err
}
} else {
slog.Info("[Bootstrap] Admin user already exists.", "email", adminEmail)
}
return nil
}

View File

@@ -34,6 +34,8 @@ type Token struct {
type AuthInfo struct {
SessionToken *Token
RefreshToken *Token
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
Subject string
}
// IdentityProvider is the interface that all IDP adapters must implement.
@@ -42,6 +44,10 @@ type IdentityProvider interface {
// GetMetadata returns the schema support information for this IDP.
// This is used for startup-time validation.
GetMetadata() (*IDPMetadata, error)
// CreateUser는 BrokerUser 스키마를 기반으로 신규 사용자를 생성하고 주체 ID(예: identity.id)를 반환합니다.
CreateUser(user *BrokerUser, password string) (string, error)
// SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다.
SignIn(loginID, password string) (*AuthInfo, error)
InitiatePasswordReset(loginID, redirectUrl string) error
VerifyPasswordResetToken(token string) (*AuthInfo, error)
UpdateUserPassword(loginID, newPassword string, r *http.Request) error

View File

@@ -0,0 +1,33 @@
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// User represents the user model stored in PostgreSQL
type User struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
Name string `gorm:"not null" json:"name"`
Phone string `json:"phone"`
Role string `gorm:"default:'user'" json:"role"` // 'admin', 'user'
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Status string `gorm:"default:'active'" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// BeforeCreate hook to generate UUID if not present
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
if u.ID == "" {
u.ID = uuid.New().String()
}
return
}

View File

@@ -2,7 +2,6 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/service"
"context"
@@ -14,7 +13,6 @@ import (
"math/rand"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -70,7 +68,7 @@ func GenerateSecureToken(length int) string {
return hex.EncodeToString(b)
}
func NewAuthHandler(redisService *service.RedisService) *AuthHandler {
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler {
projectID := os.Getenv("DESCOPE_PROJECT_ID")
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
@@ -86,13 +84,6 @@ func NewAuthHandler(redisService *service.RedisService) *AuthHandler {
}
}
idpProvider, err := idp.InitializeProvider()
if err != nil {
slog.Error("Failed to initialize IDP Provider", "error", err)
// Depending on the application's needs, you might want to panic here
// if the IDP provider is essential for the application to run.
}
return &AuthHandler{
ProjectID: projectID,
SmsService: service.NewSmsService(),
@@ -342,12 +333,11 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Phone not verified"})
}
// 3. Create User in Descope
if h.DescopeClient == nil {
if h.IdpProvider == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"})
}
// Normalize Phone for Descope (E.164)
// Normalize Phone (E.164 형태로 보관)
normalizedPhone := strings.ReplaceAll(req.Phone, "-", "")
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
if strings.HasPrefix(normalizedPhone, "010") {
@@ -356,54 +346,42 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
normalizedPhone = "+" + normalizedPhone
}
descopeUser := &descope.UserRequest{}
descopeUser.Email = req.Email
descopeUser.Phone = normalizedPhone
descopeUser.Name = req.Name
descopeUser.CustomAttributes = map[string]any{
// IDP에 전달할 BrokerUser 스키마 구성
attributes := map[string]interface{}{
"department": req.Department,
"affiliationType": req.AffiliationType,
"companyCode": req.CompanyCode,
"department": req.Department,
"termsAccepted": req.TermsAccepted,
"createdAt": time.Now().Format(time.RFC3339),
// grade는 기존 스키마 필수 키이므로 기본값을 설정
"grade": "member",
}
brokerUser := &domain.BrokerUser{
Email: req.Email,
Name: req.Name,
PhoneNumber: normalizedPhone,
Attributes: attributes,
}
// Create user
// Note: Descope `Create` does not set password. We usually need `Create` then `Password().Update`
// or use a specialized signup flow.
// `Management.User().Create` creates a user but doesn't set a password credential immediately unless specified?
// Actually `User().Create` creates the identity.
// To set password, we use `h.DescopeClient.Management.User().SetPassword(...)`
// Check if user exists (Double check)
exists, _ := h.DescopeClient.Management.User().Load(context.Background(), req.Email)
if exists != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
}
// Create
_, err := h.DescopeClient.Management.User().Create(context.Background(), req.Email, descopeUser)
providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password)
if err != nil {
slog.Error("[Signup] Failed to create user in Descope", "error", err)
slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err)
if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"})
}
// Set Password
err = h.DescopeClient.Management.User().SetPassword(context.Background(), req.Email, req.Password)
if err != nil {
slog.Error("[Signup] Failed to set password", "error", err)
// Rollback? Delete user?
h.DescopeClient.Management.User().Delete(context.Background(), req.Email)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": fmt.Sprintf("Failed to set password: %v", err)})
}
// 4. Cleanup Redis
h.RedisService.Delete(emailKey)
h.RedisService.Delete(phoneKey)
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType)
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID)
return c.JSON(fiber.Map{"success": true, "message": "User registered successfully"})
return c.JSON(fiber.Map{
"success": true,
"message": "User registered successfully",
"provider": h.IdpProvider.Name(),
"subject": providerID,
})
}
// --- Helpers ---
@@ -750,38 +728,43 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
ale.Log(slog.LevelInfo, "Attempting to login")
if h.DescopeClient == nil {
if h.IdpProvider == nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Descope Client is nil!"
ale.Log(slog.LevelError, "Descope Client is nil")
ale.DescopeError = "IDP Provider is nil"
ale.Log(slog.LevelError, "IDP Provider is nil")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
}
// Sign in using Descope
authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil)
authInfo, err := h.IdpProvider.SignIn(loginID, req.Password)
if err != nil {
ale.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = err.Error()
ale.Log(slog.LevelWarn, "Descope sign-in failed")
// [Changed] Check if it's a "User not found" error to be more specific
if strings.Contains(err.Error(), "E062107") || strings.Contains(err.Error(), "not found") {
ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
}
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime)
ale.SessionJwt = authInfo.SessionToken.JWT
ale.Log(slog.LevelInfo, "Login successful")
return c.JSON(fiber.Map{
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
resp := fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT,
"status": "ok",
})
"provider": h.IdpProvider.Name(),
}
if authInfo.RefreshToken != nil {
resp["refreshJwt"] = authInfo.RefreshToken.JWT
}
if authInfo.Subject != "" {
resp["subject"] = authInfo.Subject
}
return c.JSON(resp)
}
// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 Descope를 통해 이메일 또는 SMS를 보냅니다.
@@ -997,6 +980,12 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
ale := logger.NewAuditLogEntry(c, "complete")
ale.Operation = "UpdateUserPassword"
providerName := "unknown"
if h.IdpProvider != nil {
providerName = h.IdpProvider.Name()
}
isDescopeProvider := strings.Contains(strings.ToLower(providerName), "descope")
var req struct {
NewPassword string `json:"newPassword"`
}
@@ -1044,78 +1033,79 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
// 디버깅을 위해 요청된 새 비밀번호를 로그로 출력
ale.Log(slog.LevelInfo, "Received new password for reset")
// Validate password complexity dynamically based on Descope policy
policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
if err != nil {
// If policy fetch fails, log warning and proceed (or fallback to basic check)
ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error())
} else {
if len(req.NewPassword) < int(policy.MinLength) {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = fmt.Sprintf("Password must be at least %d characters long", policy.MinLength)
ale.Log(slog.LevelWarn, "Validation failed: password too short")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
}
if policy.Lowercase {
if ok, _ := regexp.MatchString(`[a-z]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one lowercase letter"
ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"})
}
}
if policy.Uppercase {
if ok, _ := regexp.MatchString(`[A-Z]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one uppercase letter"
ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"})
}
}
if policy.Number {
if ok, _ := regexp.MatchString(`[0-9]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one number"
ale.Log(slog.LevelWarn, "Validation failed: no number")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"})
}
}
if policy.NonAlphanumeric {
if ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one special character"
ale.Log(slog.LevelWarn, "Validation failed: no special character")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"})
}
}
if len(req.NewPassword) < 8 {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must be at least 8 characters long"
ale.Log(slog.LevelWarn, "Validation failed: password too short (fallback policy)")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
}
ale.Log(slog.LevelInfo, "Attempting to update password via Descope Auth API")
if isDescopeProvider && h.DescopeClient != nil {
// Validate password complexity (Descope only)
policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
if err != nil {
ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error())
} else {
if len(req.NewPassword) < int(policy.MinLength) {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = fmt.Sprintf("Password must be at least %d characters long", policy.MinLength)
ale.Log(slog.LevelWarn, "Validation failed: password too short")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
}
if policy.Lowercase {
if ok, _ := regexp.MatchString(`[a-z]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one lowercase letter"
ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"})
}
}
if policy.Uppercase {
if ok, _ := regexp.MatchString(`[A-Z]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one uppercase letter"
ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"})
}
}
if policy.Number {
if ok, _ := regexp.MatchString(`[0-9]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one number"
ale.Log(slog.LevelWarn, "Validation failed: no number")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"})
}
}
if policy.NonAlphanumeric {
if ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one special character"
ale.Log(slog.LevelWarn, "Validation failed: no special character")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"})
}
}
}
} else if isDescopeProvider && h.DescopeClient == nil {
ale.Log(slog.LevelWarn, "Descope selected but client is nil; skipping policy validation")
}
// Descope Management API를 통해 비밀번호 업데이트
if h.DescopeClient == nil {
ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName))
if h.IdpProvider == nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Descope Client is nil!"
ale.Log(slog.LevelError, "Descope Client is nil")
ale.DescopeError = "IDP Provider is nil"
ale.Log(slog.LevelError, "IDP Provider is nil")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
}
if err := h.DescopeClient.Management.User().SetActivePassword(context.Background(), loginID, req.NewPassword); err != nil {
// Descope 에러 상세를 감사 로그에 포함
if de, ok := err.(*descope.Error); ok {
if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
if statusInt, convErr := strconv.Atoi(fmt.Sprintf("%v", statusRaw)); convErr == nil {
ale.DescopeStatus = statusInt
}
}
ale.DescopeBody = de.Message
}
if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); err != nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = err.Error()

View File

@@ -3,12 +3,26 @@ package idp
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
)
// Ory 계열(kratos/hydra)와 Descope 등 공급자 문자열을 정규화하기 위한 매핑.
var providerAliases = map[string]string{
"ory": "ory",
"hydra": "ory",
"kratos": "ory",
"ory-kratos": "ory",
"ory_hydra": "ory",
"ory_kratos": "ory",
"descope": "descope",
"descope_sso": "descope",
}
// getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다.
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
@@ -22,42 +36,182 @@ func getEnv(key, fallback string) string {
func InitializeProvider() (domain.IdentityProvider, error) {
rawProviders := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다.
providers := strings.Split(rawProviders, ",")
slog.Info("Initializing IDP", "providers", rawProviders)
slog.Info("Initializing IDP chain", "providers", rawProviders)
var initialized []domain.IdentityProvider
for _, p := range providers {
providerName := strings.TrimSpace(strings.ToLower(p))
if canonical, ok := providerAliases[providerName]; ok {
providerName = canonical
}
switch providerName {
case "ory":
// Kratos/Hydra 주 공급자
oryProvider := service.NewOryProvider()
initialized = append(initialized, oryProvider)
case "descope":
descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
// 선택된 공급자에 대한 키가 설정되었는지 확인하기 위한 기본 유효성 검사
if descopeProjectID == "" || descopeManagementKey == "" {
return nil, fmt.Errorf("DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY must be set for the 'descope' provider")
slog.Warn("Skipping Descope provider due to missing credentials")
continue
}
return service.NewDescopeProvider(descopeProjectID, descopeManagementKey), nil
initialized = append(initialized, service.NewDescopeProvider(descopeProjectID, descopeManagementKey))
// --- 향후 공급자 구현 ---
// case "ory":
// // oryURL := getEnv("ORY_URL", "")
// // if oryURL == "" {
// // return nil, fmt.Errorf("ORY_URL must be set for the 'ory' provider")
// // }
// // return service.NewOryProvider(oryURL), nil
// // return nil, fmt.Errorf(\"'ory' provider is not yet implemented\")
// case "keycloak":
// // keycloakURL := getEnv("KEYCLOAK_URL", "")
// // keycloakRealm := getEnv("KEYCLOAK_REALM", "")
// // if keycloakURL == "" || keycloakRealm == "" {
// // return nil, fmt.Errorf("KEYCLOAK_URL and KEYCLOAK_REALM must be set for the 'keycloak' provider")
// // }
// // return service.NewKeycloakProvider(keycloakURL, keycloakRealm), nil
// // return nil, fmt.Errorf(\"'keycloak' provider is not yet implemented\")
default:
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
}
}
return nil, fmt.Errorf("unsupported or unknown IDP_PROVIDER specified: %s", rawProviders)
if len(initialized) == 0 {
return nil, fmt.Errorf("no valid IDP_PROVIDER entries configured from: %s", rawProviders)
}
if len(initialized) == 1 {
slog.Info("Initialized IDP provider", "provider", initialized[0].Name())
return initialized[0], nil
}
chain := newChainedProvider(initialized)
slog.Info("Initialized IDP provider chain", "providers", chain.Name())
return chain, nil
}
// newChainedProvider는 우선순위 순으로 IDP를 시도하는 체인을 생성합니다.
func newChainedProvider(providers []domain.IdentityProvider) domain.IdentityProvider {
names := make([]string, len(providers))
for i, p := range providers {
names[i] = p.Name()
}
return &chainedProvider{
providers: providers,
names: names,
}
}
// chainedProvider는 다중 IDP를 우선순위대로 호출하며 실패 시 폴백합니다.
type chainedProvider struct {
providers []domain.IdentityProvider
names []string
}
func (c *chainedProvider) Name() string {
return strings.Join(c.names, " > ")
}
func (c *chainedProvider) GetMetadata() (*domain.IDPMetadata, error) {
supported := make([]string, 0)
seen := make(map[string]bool)
for _, p := range c.providers {
meta, err := p.GetMetadata()
if err != nil {
return nil, fmt.Errorf("failed to fetch metadata from %s: %w", p.Name(), err)
}
for _, field := range meta.SupportedFields {
if !seen[field] {
seen[field] = true
supported = append(supported, field)
}
}
}
return &domain.IDPMetadata{SupportedFields: supported}, nil
}
func (c *chainedProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
var errs []error
for idx, p := range c.providers {
id, err := p.CreateUser(user, password)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "CreateUser", "error", err)
continue
}
if idx > 0 {
slog.Info("IDP fallback succeeded", "operation", "CreateUser", "provider", p.Name())
}
return id, nil
}
if len(errs) == 0 {
return "", fmt.Errorf("no IDP providers available for CreateUser")
}
return "", fmt.Errorf("all IDP providers failed for CreateUser: %w", errors.Join(errs...))
}
func (c *chainedProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
var errs []error
for idx, p := range c.providers {
info, err := p.SignIn(loginID, password)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "SignIn", "error", err)
continue
}
if idx > 0 {
slog.Info("IDP fallback succeeded", "operation", "SignIn", "provider", p.Name())
}
return info, nil
}
if len(errs) == 0 {
return nil, fmt.Errorf("no IDP providers available for SignIn")
}
return nil, fmt.Errorf("all IDP providers failed for SignIn: %w", errors.Join(errs...))
}
func (c *chainedProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
return c.tryProviders("InitiatePasswordReset", func(p domain.IdentityProvider) error {
return p.InitiatePasswordReset(loginID, redirectUrl)
})
}
func (c *chainedProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
var errs []error
for idx, p := range c.providers {
info, err := p.VerifyPasswordResetToken(token)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
slog.Warn("IDP VerifyPasswordResetToken failed", "provider", p.Name(), "error", err)
continue
}
if idx > 0 {
slog.Info("IDP fallback succeeded", "operation", "VerifyPasswordResetToken", "provider", p.Name())
}
return info, nil
}
if len(errs) == 0 {
return nil, fmt.Errorf("no IDP providers available for VerifyPasswordResetToken")
}
return nil, fmt.Errorf("all IDP providers failed for VerifyPasswordResetToken: %w", errors.Join(errs...))
}
func (c *chainedProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
return c.tryProviders("UpdateUserPassword", func(p domain.IdentityProvider) error {
return p.UpdateUserPassword(loginID, newPassword, r)
})
}
func (c *chainedProvider) tryProviders(operation string, fn func(domain.IdentityProvider) error) error {
var errs []error
for idx, p := range c.providers {
if err := fn(p); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", operation, "error", err)
continue
}
if idx > 0 {
slog.Info("IDP fallback succeeded", "operation", operation, "provider", p.Name())
}
return nil
}
if len(errs) == 0 {
return fmt.Errorf("no IDP providers available for %s", operation)
}
return fmt.Errorf("all IDP providers failed for %s: %w", operation, errors.Join(errs...))
}

View File

@@ -0,0 +1,115 @@
package idp
import (
"baron-sso-backend/internal/domain"
"errors"
"net/http"
"reflect"
"strings"
"testing"
)
type stubProvider struct {
name string
metadata []string
createErr error
initiateErr error
verifyErr error
updateErr error
signInErr error
initiateCalls int
verifyCalls int
updateCalls int
signInCalls int
createCalls int
verifyResponse *domain.AuthInfo
}
func (s *stubProvider) Name() string { return s.name }
func (s *stubProvider) GetMetadata() (*domain.IDPMetadata, error) {
return &domain.IDPMetadata{SupportedFields: s.metadata}, nil
}
func (s *stubProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
s.createCalls++
if s.createErr != nil {
return "", s.createErr
}
return "created-id", nil
}
func (s *stubProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
s.signInCalls++
if s.signInErr != nil {
return nil, s.signInErr
}
return &domain.AuthInfo{Subject: "subject-123"}, nil
}
func (s *stubProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
s.initiateCalls++
return s.initiateErr
}
func (s *stubProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
s.verifyCalls++
if s.verifyErr != nil {
return nil, s.verifyErr
}
if s.verifyResponse != nil {
return s.verifyResponse, nil
}
return &domain.AuthInfo{}, nil
}
func (s *stubProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
s.updateCalls++
return s.updateErr
}
func TestChainedProviderMetadataUnion(t *testing.T) {
p1 := &stubProvider{name: "primary", metadata: []string{"id", "email"}}
p2 := &stubProvider{name: "backup", metadata: []string{"email", "phone_number", "grade"}}
chain := newChainedProvider([]domain.IdentityProvider{p1, p2})
meta, err := chain.GetMetadata()
if err != nil {
t.Fatalf("GetMetadata returned error: %v", err)
}
expected := []string{"id", "email", "phone_number", "grade"}
if !reflect.DeepEqual(meta.SupportedFields, expected) {
t.Fatalf("metadata mismatch: got %v, want %v", meta.SupportedFields, expected)
}
}
func TestChainedProviderUpdateUserPasswordFallback(t *testing.T) {
p1 := &stubProvider{name: "primary", metadata: []string{"id"}, updateErr: errors.New("boom")}
p2 := &stubProvider{name: "backup", metadata: []string{"id"}}
chain := newChainedProvider([]domain.IdentityProvider{p1, p2})
if err := chain.UpdateUserPassword("user@example.com", "Sup3r!Pass123", nil); err != nil {
t.Fatalf("expected fallback to succeed, got error: %v", err)
}
if p1.updateCalls != 1 || p2.updateCalls != 1 {
t.Fatalf("unexpected call counts: p1=%d p2=%d", p1.updateCalls, p2.updateCalls)
}
}
func TestChainedProviderUpdateUserPasswordAllFail(t *testing.T) {
p1 := &stubProvider{name: "primary", metadata: []string{"id"}, updateErr: errors.New("fail1")}
p2 := &stubProvider{name: "backup", metadata: []string{"id"}, updateErr: errors.New("fail2")}
chain := newChainedProvider([]domain.IdentityProvider{p1, p2})
err := chain.UpdateUserPassword("user@example.com", "Sup3r!Pass123", nil)
if err == nil {
t.Fatalf("expected error when all providers fail")
}
if !strings.Contains(err.Error(), "all IDP providers failed") {
t.Fatalf("unexpected error: %v", err)
}
if p1.updateCalls != 1 || p2.updateCalls != 1 {
t.Fatalf("unexpected call counts: p1=%d p2=%d", p1.updateCalls, p2.updateCalls)
}
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/descope/go-sdk/descope"
@@ -69,6 +70,81 @@ func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) {
}, 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),
},
Subject: authInfo.User.UserID,
}
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) InitiatePasswordReset(loginID, redirectUrl string) error {
ctx := context.Background()
err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)

View File

@@ -0,0 +1,331 @@
package service
import (
"baron-sso-backend/internal/domain"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"time"
)
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
// 지금은 스키마 메타데이터만 반환하며, 나머지 동작은 후속 작업에서 구현합니다.
type OryProvider struct {
KratosAdminURL string
KratosPublicURL string
HydraAdminURL string
HTTPClient *http.Client
}
func NewOryProvider() *OryProvider {
return &OryProvider{
KratosAdminURL: getenv("KRATOS_ADMIN_URL", "http://kratos:4434"),
KratosPublicURL: getenv("KRATOS_PUBLIC_URL", "http://kratos:4433"),
HydraAdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
}
}
func (o *OryProvider) Name() string {
return "Ory (Kratos/Hydra)"
}
// GetMetadata는 BrokerUser가 요구하는 필드를 Kratos traits에 매핑 가능하다는 가정으로 반환합니다.
func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
return &domain.IDPMetadata{
SupportedFields: []string{
"id", "email", "name", "phone_number",
"grade", "department", "affiliationType", "companyCode",
},
}, nil
}
// CreateUser는 Kratos Admin API를 통해 identity를 생성합니다.
func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
if user == nil {
return "", fmt.Errorf("ory provider: user payload is nil")
}
if user.Email == "" || password == "" {
return "", fmt.Errorf("ory provider: email and password are required")
}
// 중복 확인
existingID, err := o.findIdentityID(user.Email)
if err != nil {
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
}
if existingID != "" {
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
}
traits := map[string]interface{}{
"email": user.Email,
"name": user.Name,
"phone_number": user.PhoneNumber,
}
for k, v := range user.Attributes {
traits[k] = v
}
payload := map[string]interface{}{
"schema_id": "default",
"traits": traits,
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"password": password,
},
},
},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("ory provider: build create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.httpClient().Do(req)
if err != nil {
return "", fmt.Errorf("ory provider: create identity request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("ory provider: create identity failed status=%d body=%s", resp.StatusCode, string(respBody))
}
var created struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
}
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
return created.ID, nil
}
// SignIn은 Kratos Public API의 login API 플로우를 사용해 세션 토큰을 발급합니다.
func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
if loginID == "" || password == "" {
return nil, fmt.Errorf("ory provider: loginID and password are required")
}
flowID, err := o.startLoginFlow()
if err != nil {
return nil, err
}
body, _ := json.Marshal(map[string]string{
"identifier": loginID,
"password": password,
"method": "password",
})
loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ory provider: build login request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("ory provider: login request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("ory provider: login failed status=%d body=%s", resp.StatusCode, string(respBody))
}
var result struct {
SessionToken string `json:"session_token"`
SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
Session struct {
Identity struct {
ID string `json:"id"`
} `json:"identity"`
} `json:"session"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("ory provider: decode login response failed: %w", err)
}
if result.SessionToken == "" {
return nil, fmt.Errorf("ory provider: empty session token returned")
}
slog.Info("Ory login successful",
"identity_id", result.Session.Identity.ID,
"loginID", loginID,
"expires_at", result.SessionTokenExpiresAt,
)
return &domain.AuthInfo{
SessionToken: &domain.Token{
JWT: result.SessionToken,
Expiration: result.SessionTokenExpiresAt,
},
Subject: result.Session.Identity.ID,
}, nil
}
// InitiatePasswordReset는 현재 내부 토큰/메일 흐름을 사용하고 있으므로 NO-OP로 둡니다.
func (o *OryProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
slog.Info("Ory InitiatePasswordReset bypassed (handled by app internal flow)", "loginID", loginID, "redirect", redirectUrl)
return nil
}
// VerifyPasswordResetToken는 내부 토큰 검증 흐름을 사용하므로 아직 구현하지 않습니다.
func (o *OryProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
return nil, fmt.Errorf("ory provider: VerifyPasswordResetToken not implemented (internal token flow expected)")
}
// UpdateUserPassword: Kratos Admin API를 통해 비밀번호를 갱신합니다.
func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
if loginID == "" || newPassword == "" {
return fmt.Errorf("ory provider: loginID or new password missing")
}
identityID, err := o.findIdentityID(loginID)
if err != nil {
return fmt.Errorf("ory provider: find identity failed: %w", err)
}
if identityID == "" {
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
}
payload := map[string]interface{}{
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"password": newPassword,
},
},
},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("ory provider: build request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.httpClient().Do(req)
if err != nil {
return fmt.Errorf("ory provider: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("ory provider: password update failed status=%d body=%s", resp.StatusCode, string(respBody))
}
slog.Info("Ory password updated via Kratos admin", "identity_id", identityID, "loginID", loginID)
return nil
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
func (o *OryProvider) findIdentityID(loginID string) (string, error) {
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
if err != nil {
return "", err
}
query := u.Query()
query.Set("credentials_identifier", loginID)
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
resp, err := o.httpClient().Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body))
}
var identities []struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
return "", fmt.Errorf("decode response failed: %w", err)
}
if len(identities) == 0 {
return "", nil
}
return identities[0].ID, nil
}
func (o *OryProvider) httpClient() *http.Client {
if o.HTTPClient != nil {
return o.HTTPClient
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
},
}
}
// startLoginFlow는 Kratos Public API에서 login flow ID를 발급받습니다.
func (o *OryProvider) startLoginFlow() (string, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL), nil)
if err != nil {
return "", fmt.Errorf("ory provider: build login flow request failed: %w", err)
}
resp, err := o.httpClient().Do(req)
if err != nil {
return "", fmt.Errorf("ory provider: login flow request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("ory provider: login flow failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("ory provider: decode login flow failed: %w", err)
}
if result.ID == "" {
return "", fmt.Errorf("ory provider: empty login flow id")
}
return result.ID, nil
}

View File

@@ -0,0 +1,149 @@
package service
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// clientForHandler returns an http.Client that routes requests to the given handler
// without real network sockets.
func clientForHandler(h http.Handler) *http.Client {
return &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
// Clone request body for handler
var bodyBytes []byte
if req.Body != nil {
bodyBytes, _ = io.ReadAll(req.Body)
}
r := httptest.NewRequest(req.Method, req.URL.String(), bytes.NewReader(bodyBytes))
r.Header = req.Header.Clone()
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Result(), nil
}),
}
}
type roundTripperFunc func(req *http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func TestUpdateUserPassword_Success(t *testing.T) {
const (
loginID = "user@example.com"
identityID = "7f0dc8c3-9d5d-4f57-b3d1-123456789abc"
newPassword = "Sup3rStr0ng!Pass#2026"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
q := r.URL.Query()
if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": identityID},
})
return
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch:
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), newPassword) {
t.Fatalf("payload missing new password, body=%s", string(body))
}
w.WriteHeader(http.StatusOK)
return
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
if err := provider.UpdateUserPassword(loginID, newPassword, nil); err != nil {
t.Fatalf("UpdateUserPassword returned error: %v", err)
}
}
func TestUpdateUserPassword_NotFound(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet {
http.NotFound(w, r)
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
err := provider.UpdateUserPassword("user@example.com", "Sup3rStr0ng!Pass#2026", nil)
if err == nil || !strings.Contains(err.Error(), "identity not found") {
t.Fatalf("expected identity not found error, got: %v", err)
}
}
func TestUpdateUserPassword_ServerError(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"},
})
return
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch:
http.Error(w, "boom", http.StatusInternalServerError)
return
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
err := provider.UpdateUserPassword("user@example.com", "Sup3rStr0ng!Pass#2026", nil)
if err == nil || !strings.Contains(err.Error(), "password update failed") {
t.Fatalf("expected server error, got: %v", err)
}
}
func TestFindIdentityID_QueryEncoding(t *testing.T) {
loginID := "user+alias@example.com"
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
values, _ := url.ParseQuery(r.URL.RawQuery)
if values.Get("credentials_identifier") != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier"))
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "id-123"},
})
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
id, err := provider.findIdentityID(loginID)
if err != nil {
t.Fatalf("findIdentityID returned error: %v", err)
}
if id != "id-123" {
t.Fatalf("expected id-123, got %s", id)
}
}

View File

@@ -21,6 +21,14 @@ func (m *MockProvider) GetMetadata() (*domain.IDPMetadata, error) {
}, nil
}
func (m *MockProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
return "", nil
}
func (m *MockProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
return &domain.AuthInfo{}, nil
}
// Stub implementations to satisfy the IdentityProvider interface for this unit test.
func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
return nil