forked from baron/baron-sso
multi IDP 모델 적용 scaffolding
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
# --- General System ---
|
# --- General System ---
|
||||||
APP_ENV=dev # 애플리케이션 실행 환경 (deve, production)
|
APP_ENV=dev # 애플리케이션 실행 환경 (dev, stage, production)
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
|
|
||||||
# --- Infrastructure Ports ---
|
# --- Infrastructure Ports ---
|
||||||
@@ -43,5 +43,5 @@ AWS_SES_SENDER=no-reply@baron.co.kr
|
|||||||
ADMIN_PASSWORD=admin
|
ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
# --- URLs for Proxy/Handoff ---
|
# --- URLs for Proxy/Handoff ---
|
||||||
FRONTEND_URL=https://ssologin.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용)
|
FRONTEND_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용)
|
||||||
BACKEND_URL=https://ssologin.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
|
BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
|
||||||
22
AGENTS.md
Normal file
22
AGENTS.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# AGENTS 가이드 (Baron SSO)
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
- Inbound Auth/Launcher와 관리(Admin) 기능을 하나의 백엔드에서 운영하되, 네임스페이스·도메인·권한으로 강하게 분리한다.
|
||||||
|
- 사용자 플로우(가입/로그인)와 관리 플로우(Descope Management Key 사용)를 명확히 구분해 보안 사고면을 축소한다.
|
||||||
|
|
||||||
|
## 현재 원칙
|
||||||
|
- **경계 분리**: `/admin/*` + admin 서브도메인에서만 관리 기능 노출. 일반 사용자 번들과 관리자 번들(또는 라우트)을 분리.
|
||||||
|
- **관리 키 취급**: Descope Management Key는 서버 내부에서만 사용, 비동기 잡/관리 API에서 래핑. 모든 관리 액션을 감사 로그/알람/레이트리밋으로 보호.
|
||||||
|
- **권한/가드**: role/permission 기반 접근 제어. 관리자 세션 TTL은 짧게, step-up MFA 고려.
|
||||||
|
|
||||||
|
## 인증 플로우 핵심
|
||||||
|
- **최초 회원가입**: SMS 인증(Enchanted Link/OTP) 필수 → 인증 성공 후 계정 생성 및 초기 세션 발급.
|
||||||
|
- **재로그인 분기 (앱 세션 보유 + 사용자 선택)**:
|
||||||
|
- 앱 로그인 상태 + 사용자가 “앱 승인” 선택: 앱을 MFA/IDPW 대체 수단으로 사용(푸시/딥링크 승인) → 승인 시 웹 세션 발급.
|
||||||
|
- 앱 세션이 없거나, 사용자가 이번 로그인에서 앱을 사용하지 않기로 선택: SMS 또는 이메일/비밀번호 경로로 진행.
|
||||||
|
- **세션 TTL**: 앱 기반 세션 유지시간을 `APP_SESSION_TTL_MINUTES` 환경 변수로 관리(기본 예: 30분).
|
||||||
|
|
||||||
|
## 작업 시 체크리스트
|
||||||
|
- 관리 기능 개발 시 admin 네임스페이스, 권한 체크, 감사 로깅, 레이트리밋을 기본 포함.
|
||||||
|
- 인증/로그인 변경 시 “폴백은 사용자 선택일 때만” 규칙을 준수하고, UI에도 선택 흐름을 노출.
|
||||||
|
- 새 설정/비밀값은 .env.sample에 반영하고 서버에서만 소비하게 설계한다.
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/handler"
|
"baron-sso-backend/internal/handler"
|
||||||
"baron-sso-backend/internal/logger"
|
"baron-sso-backend/internal/logger"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
|
"baron-sso-backend/internal/validator"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -59,6 +61,27 @@ func main() {
|
|||||||
"redis_addr", getEnv("REDIS_ADDR", "redis:6379"),
|
"redis_addr", getEnv("REDIS_ADDR", "redis:6379"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Fail-Fast Schema Validation ---
|
||||||
|
// Initialize the IDP Provider (Descope)
|
||||||
|
descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
|
||||||
|
descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
|
||||||
|
|
||||||
|
// We create a provider instance to check schema compatibility.
|
||||||
|
// This ensures that our BrokerUser model requirements (e.g. custom attributes)
|
||||||
|
// are supported by the configured IDP.
|
||||||
|
idpProvider := service.NewDescopeProvider(descopeProjectID, descopeManagementKey)
|
||||||
|
|
||||||
|
if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil {
|
||||||
|
slog.Error("❌ [CRITICAL] Broker Schema Mismatch",
|
||||||
|
"idp", idpProvider.Name(),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
fmt.Printf("\n!!! CRITICAL ERROR: IDP Schema Mismatch !!!\n%v\n\n", err)
|
||||||
|
os.Exit(1) // Break the build/deployment
|
||||||
|
}
|
||||||
|
slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name())
|
||||||
|
// -----------------------------------
|
||||||
|
|
||||||
// 2. Initialize DB Connections
|
// 2. Initialize DB Connections
|
||||||
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
|
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
|
||||||
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
|
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
|
||||||
|
|||||||
28
backend/internal/domain/idp_models.go
Normal file
28
backend/internal/domain/idp_models.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// BrokerUser is the standard user model used within Baron SSO business logic.
|
||||||
|
// It defines the canonical set of fields that must be supported by any underlying IDP.
|
||||||
|
type BrokerUser struct {
|
||||||
|
ID string `json:"id" required:"true"`
|
||||||
|
Email string `json:"email" required:"true"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
// Attributes stores custom user attributes.
|
||||||
|
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
|
||||||
|
Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDPMetadata represents the schema capabilities of an Identity Provider.
|
||||||
|
type IDPMetadata struct {
|
||||||
|
// SupportedFields lists the BrokerUser fields (json tag names) that the IDP supports.
|
||||||
|
// For custom attributes, use the key name directly (e.g., "grade").
|
||||||
|
SupportedFields []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentityProvider is the interface that all IDP adapters must implement.
|
||||||
|
type IdentityProvider interface {
|
||||||
|
Name() string
|
||||||
|
// GetMetadata returns the schema support information for this IDP.
|
||||||
|
// This is used for startup-time validation.
|
||||||
|
GetMetadata() (*IDPMetadata, error)
|
||||||
|
}
|
||||||
62
backend/internal/service/descope_service.go
Normal file
62
backend/internal/service/descope_service.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/descope/go-sdk/descope/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DescopeProvider struct {
|
||||||
|
Client *client.DescopeClient
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
62
backend/internal/validator/schema_validator.go
Normal file
62
backend/internal/validator/schema_validator.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateIDPCompatibility checks if the provided IDP supports all required fields defined in the BrokerUser model.
|
||||||
|
func ValidateIDPCompatibility(brokerModel interface{}, idp domain.IdentityProvider) error {
|
||||||
|
metadata, err := idp.GetMetadata()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch metadata from IDP %s: %w", idp.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
supportedMap := make(map[string]bool)
|
||||||
|
for _, f := range metadata.SupportedFields {
|
||||||
|
supportedMap[f] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
t := reflect.TypeOf(brokerModel)
|
||||||
|
if t.Kind() == reflect.Ptr {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
|
||||||
|
// Check "required" tag
|
||||||
|
isRequired := field.Tag.Get("required") == "true"
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
fieldName := strings.Split(jsonTag, ",")[0]
|
||||||
|
|
||||||
|
// Skip if fieldName is empty or if it's the Attributes map (handled separately)
|
||||||
|
if fieldName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if fieldName != "attributes" {
|
||||||
|
if isRequired && !supportedMap[fieldName] {
|
||||||
|
return fmt.Errorf("IDP %s does not support required field: %s", idp.Name(), fieldName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check "required_keys" tag for map types (Custom Attributes)
|
||||||
|
if fieldName == "attributes" {
|
||||||
|
reqKeys := field.Tag.Get("required_keys")
|
||||||
|
if reqKeys != "" {
|
||||||
|
keys := strings.Split(reqKeys, ",")
|
||||||
|
for _, key := range keys {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if !supportedMap[key] {
|
||||||
|
return fmt.Errorf("IDP %s does not support required custom attribute: %s", idp.Name(), key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
78
backend/internal/validator/schema_validator_test.go
Normal file
78
backend/internal/validator/schema_validator_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockProvider는 IdentityProvider 인터페이스를 구현하는 테스트용 구조체입니다.
|
||||||
|
type MockProvider struct {
|
||||||
|
Supported []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockProvider) Name() string {
|
||||||
|
return "MockIDP"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||||
|
return &domain.IDPMetadata{
|
||||||
|
SupportedFields: m.Supported,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateIDPCompatibility(t *testing.T) {
|
||||||
|
// BrokerUser 모델은 다음과 같이 정의되어 있다고 가정합니다 (idp_models.go 참조):
|
||||||
|
// ID (required), Email (required), Name, PhoneNumber
|
||||||
|
// Attributes (required_keys: "grade", "department")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
supported []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "성공: 모든 필수 필드 지원",
|
||||||
|
supported: []string{"id", "email", "name", "grade", "department"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "성공: 선택 필드(name) 누락이어도 성공",
|
||||||
|
supported: []string{"id", "email", "grade", "department"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "실패: 필수 필드(id) 누락",
|
||||||
|
supported: []string{"email", "grade", "department"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "실패: 필수 필드(email) 누락",
|
||||||
|
supported: []string{"id", "grade", "department"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "실패: 커스텀 속성(grade) 누락",
|
||||||
|
supported: []string{"id", "email", "department"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "실패: 커스텀 속성(department) 누락",
|
||||||
|
supported: []string{"id", "email", "grade"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 테스트용 IDP Provider 생성
|
||||||
|
mockIDP := &MockProvider{Supported: tt.supported}
|
||||||
|
|
||||||
|
// 검증 수행
|
||||||
|
err := ValidateIDPCompatibility(domain.BrokerUser{}, mockIDP)
|
||||||
|
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ValidateIDPCompatibility() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,14 @@ services:
|
|||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./docker/init-metadata:/docker-entrypoint-initdb.d
|
||||||
networks:
|
networks:
|
||||||
- baron_net
|
- baron_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-baron} -d ${DB_NAME:-baron_sso}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
clickhouse:
|
clickhouse:
|
||||||
|
|||||||
42
docker/init-metadata/01_init_metadata.sql
Normal file
42
docker/init-metadata/01_init_metadata.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- Metadata DB Initialization for Baron SSO
|
||||||
|
-- Purpose: Manage Relying Parties (RP) and User Consent
|
||||||
|
|
||||||
|
-- 1. Relying Parties (RP) Table
|
||||||
|
CREATE TABLE IF NOT EXISTS relying_parties (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
client_secret VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
redirect_uris TEXT[] NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
logo_url VARCHAR(2048),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. User Consents Table
|
||||||
|
-- Tracks which scopes/permissions a user has granted to an RP
|
||||||
|
CREATE TABLE IF NOT EXISTS user_consents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id VARCHAR(255) NOT NULL, -- Subject ID from IDP
|
||||||
|
rp_id UUID NOT NULL REFERENCES relying_parties(id),
|
||||||
|
scopes TEXT[] NOT NULL,
|
||||||
|
granted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
UNIQUE(user_id, rp_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rp_client_id ON relying_parties(client_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consent_user ON user_consents(user_id);
|
||||||
|
|
||||||
|
-- 3. Seed Data (Optional)
|
||||||
|
-- Initial RP for testing purposes
|
||||||
|
INSERT INTO relying_parties (client_id, client_secret, name, redirect_uris, description)
|
||||||
|
VALUES (
|
||||||
|
'baron-admin-client',
|
||||||
|
'secret-key-12345',
|
||||||
|
'Baron Admin Console',
|
||||||
|
ARRAY['http://localhost:5000/callback', 'https://sso.hmac.kr/callback'],
|
||||||
|
'Official Admin Console for Baron SSO'
|
||||||
|
) ON CONFLICT (client_id) DO NOTHING;
|
||||||
34
frontend/.env.sample
Normal file
34
frontend/.env.sample
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# ==========================================
|
||||||
|
# Baron SSO - Unified Environment Configuration
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# --- General System ---
|
||||||
|
APP_ENV=development
|
||||||
|
TZ=Asia/Seoul
|
||||||
|
|
||||||
|
# --- Infrastructure Ports ---
|
||||||
|
DB_PORT=5432
|
||||||
|
CLICKHOUSE_PORT_HTTP=8123
|
||||||
|
CLICKHOUSE_PORT_NATIVE=9000
|
||||||
|
BACKEND_PORT=3000
|
||||||
|
FRONTEND_PORT=5000
|
||||||
|
|
||||||
|
# --- Database Credentials (PostgreSQL) ---
|
||||||
|
DB_USER=baron
|
||||||
|
DB_PASSWORD=password
|
||||||
|
DB_NAME=baron_sso
|
||||||
|
|
||||||
|
# --- Backend Configuration ---
|
||||||
|
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
||||||
|
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
||||||
|
REDIS_ADDR=redis:6379
|
||||||
|
|
||||||
|
# --- Frontend Configuration ---
|
||||||
|
# Descope Project ID (Required for Auth)
|
||||||
|
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
|
||||||
|
|
||||||
|
# --- Naver Cloud Services ---
|
||||||
|
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
|
||||||
|
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
|
||||||
|
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
|
||||||
|
NAVER_SENDER_PHONE_NUMBER=...
|
||||||
93
page_content.txt
Normal file
93
page_content.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# 멀티 IDP 아키텍처 및 마이그레이션 전략
|
||||||
|
|
||||||
|
본 문서는 Primary IDP 변경(예: Descope → Hydra/Authentik) 시 시스템의 유연성을 보장하기 위한 아키텍처 설계 전략을 기술합니다.
|
||||||
|
|
||||||
|
## 핵심 전략
|
||||||
|
|
||||||
|
1. **Broker 패턴 고도화**: 백엔드가 모든 인증 요청을 중계하며, 클라이언트는 특정 IDP에 종속되지 않습니다.
|
||||||
|
2. **추상화 계층 (Abstraction Layer)**: 비즈니스 로직은 `IdentityProvider` 인터페이스에만 의존하며, 구체적인 구현체(Descope, Hydra 등)는 알 필요가 없습니다.
|
||||||
|
3. **식별자 분리 (Identifier Decoupling)**: 외부 IDP의 `sub` 값과 내부 시스템의 `user_id`를 분리하여 매핑합니다.
|
||||||
|
|
||||||
|
## 상세 설계
|
||||||
|
|
||||||
|
### 1. IdentityProvider 인터페이스 확장
|
||||||
|
단순 메타데이터 조회를 넘어, 인증의 핵심 라이프사이클을 모두 포함하도록 인터페이스를 확장해야 합니다.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type IdentityProvider interface {
|
||||||
|
// 메타데이터 및 스키마 검증
|
||||||
|
Name() string
|
||||||
|
GetMetadata() (*IDPMetadata, error)
|
||||||
|
|
||||||
|
// 핵심 인증/인가 기능
|
||||||
|
SignUp(ctx context.Context, user *BrokerUser, password string) (*AuthResult, error)
|
||||||
|
Login(ctx context.Context, loginID, password string) (*AuthResult, error)
|
||||||
|
VerifyToken(ctx context.Context, token string) (*TokenInfo, error)
|
||||||
|
|
||||||
|
// 사용자 관리
|
||||||
|
GetUser(ctx context.Context, id string) (*BrokerUser, error)
|
||||||
|
UpdateUser(ctx context.Context, user *BrokerUser) error
|
||||||
|
DeleteUser(ctx context.Context, id string) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Provider Factory 패턴
|
||||||
|
설정(`config.yaml` 또는 환경변수)에 따라 사용할 IDP 구현체를 런타임에 결정합니다.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewIdentityProvider(config Config) (domain.IdentityProvider, error) {
|
||||||
|
switch config.IDPType {
|
||||||
|
case "descope":
|
||||||
|
return service.NewDescopeProvider(config.Descope)
|
||||||
|
case "hydra":
|
||||||
|
return service.NewHydraProvider(config.Hydra)
|
||||||
|
case "authentik":
|
||||||
|
return service.NewAuthentikProvider(config.Authentik)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported IDP type: %s", config.IDPType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 식별자 매핑 (ID Mapping)
|
||||||
|
IDP 변경 시 가장 큰 문제는 사용자 고유 ID(`sub`)가 바뀌는 것입니다. 이를 해결하기 위해 Baron SSO 내부 전용 UUID를 사용하고 매핑 테이블을 둡니다.
|
||||||
|
|
||||||
|
* **User Table (Baron Internal)**: `id` (UUID), `email`, ...
|
||||||
|
* **IDP Link Table**: `baron_user_id`, `idp_provider` (e.g., descope), `idp_subject_id` (e.g., U12345)
|
||||||
|
|
||||||
|
## 구현 예시 (Descope)
|
||||||
|
|
||||||
|
현재 단계에서 즉시 사용 가능한 `DescopeProvider`의 구현 예시입니다.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DescopeProvider는 IdentityProvider 인터페이스를 구현합니다.
|
||||||
|
type DescopeProvider struct {
|
||||||
|
Client *client.DescopeClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DescopeProvider) SignUp(ctx context.Context, user *domain.BrokerUser, password string) (*domain.AuthResult, error) {
|
||||||
|
// BrokerUser를 Descope User 객체로 변환
|
||||||
|
descopeUser := &descope.User{
|
||||||
|
Name: user.Name,
|
||||||
|
Email: user.Email,
|
||||||
|
Phone: user.PhoneNumber,
|
||||||
|
CustomAttributes: user.Attributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDK 호출
|
||||||
|
authInfo, err := d.Client.Auth.Password().SignUp(ctx, user.Email, descopeUser, password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err\n }
|
||||||
|
|
||||||
|
// 결과 반환
|
||||||
|
return &domain.AuthResult{
|
||||||
|
UserID: authInfo.User.UserID,
|
||||||
|
Token: authInfo.SessionToken.JWT,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 향후 지원 후보 (Candidates)
|
||||||
|
|
||||||
|
* **Ory Hydra**: 자체적인 OAuth2/OIDC 서버 구축이 필요할 때 적합. 높은 커스터마이징 가능.
|
||||||
|
* **Authentik**: 오픈소스 IDP로, 설치형(Self-hosted) 환경에서 강력한 기능을 제공.
|
||||||
Reference in New Issue
Block a user