From aa244710122f51961225b32b5705f1c1ac4da86b Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 28 Jan 2026 15:40:19 +0900 Subject: [PATCH 01/14] =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.ory.yaml | 10 ++- docker/ory/keto/keto.yml | 3 + docker/ory/keto/namespaces.yml | 13 ++- docs/compose-ory.md | 154 +++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 docs/compose-ory.md diff --git a/compose.ory.yaml b/compose.ory.yaml index 1743a1c5..3a2bfd34 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -12,7 +12,11 @@ services: networks: - ory-net healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}", + ] interval: 5s timeout: 5s retries: 5 @@ -91,7 +95,7 @@ services: image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20 - command: migrate sql -e --yes + command: migrate sql up -e --yes depends_on: postgres_ory: condition: service_healthy @@ -126,7 +130,7 @@ services: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20 volumes: - ./docker/ory/keto:/etc/config/keto - command: migrate up -c /etc/config/keto/keto.yml --yes + command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] depends_on: postgres_ory: condition: service_healthy diff --git a/docker/ory/keto/keto.yml b/docker/ory/keto/keto.yml index 3f8023d1..d4521587 100644 --- a/docker/ory/keto/keto.yml +++ b/docker/ory/keto/keto.yml @@ -1,4 +1,5 @@ version: v0.11.0 +dsn: ${DSN} serve: read: host: 0.0.0.0 @@ -6,7 +7,9 @@ serve: write: host: 0.0.0.0 port: 4467 + namespaces: location: file:///etc/config/keto/namespaces.yml + log: level: debug diff --git a/docker/ory/keto/namespaces.yml b/docker/ory/keto/namespaces.yml index 3b11865b..dca30db1 100644 --- a/docker/ory/keto/namespaces.yml +++ b/docker/ory/keto/namespaces.yml @@ -1,7 +1,6 @@ -namespaces: - - id: 0 - name: default - - id: 1 - name: roles - - id: 2 - name: permissions +- id: 0 + name: default +- id: 1 + name: roles +- id: 2 + name: permissions diff --git a/docs/compose-ory.md b/docs/compose-ory.md new file mode 100644 index 00000000..57d98ad8 --- /dev/null +++ b/docs/compose-ory.md @@ -0,0 +1,154 @@ +## 서비스 역할 + +### 1) `postgres_ory` + +- Ory 스택(Kratos/Hydra/Keto)이 공용으로 쓰는 PostgreSQL DB +- `healthcheck`로 DB 준비 상태를 다른 서비스들이 기다릴 수 있게 함 + +### 2) `kratos-migrate` + +- Kratos DB 스키마 마이그레이션을 수행하는 1회성 컨테이너 +- Postgres가 healthy가 된 뒤에 실행되고, 성공해야 Kratos가 뜸 + +### 3) `kratos` + +- **인증/회원(Identity) 담당**: 로그인/회원가입/리커버리/검증 등 Self-service flow 제공 +- 포트 + - `4433`(public): 브라우저/클라이언트가 접근하는 API + - `4434`(admin): 관리용 API(내부에서만 쓰는 게 일반적) + +- `--watch-courier`는 이메일/메시지 발송 관련(개발 모드) 흐름을 돕는 옵션 + +### 4) `kratos-mcp-server` (현재는 `profiles: mcp`) + +- Kratos Admin API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지” +- 사람/브라우저가 직접 쓰는 서비스라기보다, 내부 도구가 붙어서 identity 관리 작업을 자동화할 때 사용 + +### 5) `kratos-ui` + +- Kratos의 로그인/회원가입 등 Self-service 화면을 제공하는 UI 서버 +- Kratos public/admin URL을 환경변수로 받아서 UI가 Kratos와 통신함 + +--- + +### 6) `hydra-migrate` + +- Hydra DB 스키마 마이그레이션을 수행하는 1회성 컨테이너 +- Postgres가 healthy가 된 뒤 실행되고, 성공해야 Hydra가 뜸 + +### 7) `hydra` + +- **OAuth2 / OIDC Provider**: authorization code 발급, access/refresh token 발급 등 +- 포트 + - `4444`(public): authorization/token/jwks 등 외부 클라이언트가 접근 + - `4445`(admin): 클라이언트 등록/관리 등 관리자 API + +- `URLS_SELF_ISSUER`, `URLS_LOGIN`, `URLS_CONSENT`로 “로그인/동의 화면을 어디서 처리할지”를 외부(backend)로 위임 + +### 8) `hydra-mcp-server` (지금은 profiles 제거되어 항상 뜸) + +- Hydra Admin/Public API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지” +- 주 용도는 OAuth 클라이언트 생성/수정/조회 자동화, 테스트 환경 세팅, 운영 자동화 등 +- 브라우저로 접속하는 포트 서비스가 아닐 가능성이 높고(ports 없음), 내부 도구가 붙어서 사용 + +--- + +### 9) `keto-migrate` + +- Keto(권한/관계 기반 접근제어) DB 마이그레이션 수행 1회성 컨테이너 +- Postgres가 healthy가 된 뒤 실행되고, 성공해야 Keto가 뜸 + +### 10) `keto` + +- **권한/정책(관계 튜플) 기반 접근제어** 담당(Ory Keto) +- 포트 + - `4466` read API + - `4467` write API + +- “누가 어떤 리소스에 어떤 관계/권한이 있는지”를 저장/조회하는 역할 + +--- + +### 11) `oathkeeper` + +- **Reverse proxy + Access rule enforcement**(인증/인가 게이트웨이) +- 일반적으로 앞단에서 요청을 받아서 “인증 여부 확인 후” 백엔드로 프록시 +- 포트 + - `4456` API(관리/디버그용으로 쓰는 경우 많음) + - `4455` Proxy(외부 트래픽이 통과하는 포트로 쓰는 경우가 많음) + +--- + +### 12) `ory_stack_check` + +- 알파인에서 curl로 Kratos/Hydra/Keto의 `/health/ready`를 폴링해서 “스택 준비 완료”를 확인하는 헬퍼 +- 준비가 끝나야 다음 단계(init-rp)가 안전하게 실행됨 + +### 13) `init-rp` + +- Hydra Admin API로 **OAuth 클라이언트(Relying Party)를 자동 등록**하는 1회성 컨테이너 +- 여기서는 `adminfront`, `devfront` 클라이언트를 만들어 둠 +- 실제 서비스 시작 시 “클라이언트가 없어서 로그인 플로우가 안 되는” 문제를 방지 + +--- + +## 네트워크 역할 + +### `ory-net` (external) + +- Postgres/Kratos/Hydra/Keto/Oathkeeper 등 Ory 스택 내부 서비스들이 서로 통신하는 공용 네트워크 +- `http://hydra:4445`, `http://kratos:4434` 같은 서비스 디스커버리가 여기서 성립 + +### `hydranet` (external) + +- Hydra가 붙는 별도 네트워크 +- `init-rp`가 `hydranet`에 붙어서 Hydra Admin API로 클라이언트 등록을 수행 + +### `kratosnet` (external) + +- Kratos가 붙는 별도 네트워크 +- 다른 애플리케이션(예: backend)이 Kratos와 통신할 때 분리된 네트워크로 구성하는 패턴 + +--- + +## 볼륨 역할 + +### `ory_postgres_data` + +- Postgres 데이터 영속화(컨테이너 재시작/재생성해도 DB 유지) + +--- + +## 확인할 서비스 + +### Kratos: + +``` +curl -i http://localhost:4433/health/ready +``` + +### Hydra: + +``` +curl -i http://localhost:4441/health/ready +``` + +### Keto: + +``` +curl -i http://localhost:4466/health/ready +``` + +### Oathkeeper: + +``` +curl -i http://localhost:4456/health/ready +``` + +### 화면이 떠야 하는 것 (UI) + +``` +http://localhost:4455/... : Kratos UI (이미 OK) + +http://localhost:5000, http://localhost:5174 : 프론트들 (이미 OK) +``` From a5e61df4ae9b322d7e5a8ee50c0c0420a77b1bc6 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 28 Jan 2026 16:53:08 +0900 Subject: [PATCH 02/14] =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.ory.yaml | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/compose.ory.yaml b/compose.ory.yaml index 3a2bfd34..600118d3 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -217,25 +217,25 @@ services: image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} environment: - HYDRA_ADMIN_URL=http://hydra:4445 - command: > - /bin/sh -c " - hydra clients create - --endpoint http://hydra:4445 - --id adminfront - --secret admin-secret - --grant-types authorization_code,refresh_token - --response-types code - --scope openid,offline_access,profile,email - --callbacks http://localhost:5000/callback; - hydra clients create - --endpoint http://hydra:4445 - --id devfront - --grant-types authorization_code,refresh_token - --response-types code - --scope openid,offline_access,profile,email - --token-endpoint-auth-method none - --callbacks http://localhost:5174/callback; - " + command: | + hydra clients create \ + --endpoint http://hydra:4445 \ + --id adminfront \ + --secret admin-secret \ + --grant-types authorization_code,refresh_token \ + --response-types code \ + --scope openid,offline_access,profile,email \ + --callbacks http://localhost:5000/callback; + + hydra clients create \ + --endpoint http://hydra:4445 \ + --id devfront \ + --grant-types authorization_code,refresh_token \ + --response-types code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --response-types code \ + --callbacks http://localhost:5174/callback; depends_on: ory_stack_check: condition: service_completed_successfully From f6c7021fdae3cbaea84147a179c295a5d15f41f5 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 28 Jan 2026 16:53:20 +0900 Subject: [PATCH 03/14] =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/descope_inbound_apps.md | 135 +++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/descope_inbound_apps.md diff --git a/docs/descope_inbound_apps.md b/docs/descope_inbound_apps.md new file mode 100644 index 00000000..eae700ba --- /dev/null +++ b/docs/descope_inbound_apps.md @@ -0,0 +1,135 @@ +**Descope Inbound App은 “OIDC 클라이언트 + 사용자 동의(Consent) + 토큰/세션 정책”을 한 화면에 묶어 제공하는 콘솔**입니다. + +--- + +## 1️⃣ Inbound App 기본 정보 (OIDC Client 메타데이터) + +**역할: OAuth/OIDC Client 정의** + +- **Inbound App Name / ID** + - OIDC `client_id`에 대응 + +- **Description** +- **Status (Verified / Unverified)** + - 사용자에게 신뢰된 앱인지 표시용 + +👉 Hydra 기준으로 보면 `hydra clients create`의 **client 메타 정보 영역** + +--- + +## 2️⃣ Scopes 관리 (권한 정의) + +**역할: “이 앱이 무엇을 요구할 수 있는가” 정의** + +### Permission Scopes + +- `full_access` +- `profile` +- `email` +- 각 scope별: + - 설명 + - Role 연계 여부 + - Mandatory 여부 + +### User Information Scopes + +- 사용자 claims에 포함될 정보 정의 +- “토큰에 항상 authorization 정보 포함” 옵션 + +👉 OIDC의 `scope` + `claims` 설계를 **UI로 추상화** + +--- + +## 3️⃣ Consents (사용자 동의) 탭 + +**역할: Descope 인바운드 앱의 핵심** + +- 사용자가 로그인 중 보게 되는 화면 +- “이 앱이 아래 권한을 요청합니다” +- Scope별 동의/거부 +- Mandatory scope는 자동 포함 + +👉 이게 **Hydra의 `consent_challenge`를 처리하는 UI**에 해당 +👉 김용연님이 **5174에 구현하라고 들은 바로 그 기능** + +--- + +## 4️⃣ Connection Information (OIDC Endpoint 묶음) + +**역할: 외부 앱이 실제로 연동할 정보** + +- **Flow Hosting URL** + - Descope가 제공하는 로그인 + consent orchestration URL + +- **Approved Redirect URIs** + - OAuth redirect whitelist + +- **Client ID / Client Secret** +- **Discovery URL** + - `/.well-known/openid-configuration` + +- **Issuer** +- **Authorization URL** +- **Token URL** +- **Audience Whitelist** +- **Default Audience 설정** + +👉 이 영역은 **OIDC 표준 설정을 전부 노출** +👉 Hydra로 치면: + +- discovery +- issuer +- `/oauth2/auth` +- `/oauth2/token` + +--- + +## 5️⃣ Session Management (토큰/세션 정책) + +**역할: 보안 정책 제어** + +### Token Format + +- User JWT 템플릿 +- Access Key JWT 템플릿 + +### Token Expiration + +- Refresh Token Timeout (예: 520주) +- Session Token Timeout (분 단위) +- Access Token Timeout + +👉 Hydra + Kratos 설정을 **앱 단위로 override**하는 개념 + +--- + +## 6️⃣ Descope Inbound App이 “한 번에 제공하는 것” 요약 + +한 문장으로 정리하면: + +> **Descope Inbound App = +> OIDC Client 관리 + Scope 정의 + Consent UI + Token/Session 정책 + Login Flow Hosting** + +--- + +## 7️⃣ 김용연님 Baron SSO(5174)와의 1:1 대응표 + +| Descope Inbound App | Baron SSO(5174) | +| ---------------------- | ------------------------------- | +| Inbound App Details | Client 관리 화면 | +| Scopes | Client Scope 설정 | +| **Consents** | **/consent 페이지 (구현 대상)** | +| Connection Information | Hydra Client 설정 | +| Session Management | 토큰 정책 설정 | + +👉 그래서 결론적으로, + +- **지금 5174 `/clients` 화면은 Descope의 “Settings 탭 일부”** +- **Consents 탭이 빠져 있어서 아직 Descope의 절반만 구현된 상태** + +--- + +## 최종 정리 한 줄 + +> **Descope Inbound App은 “OIDC Client + 사용자 동의 + 보안 정책”을 묶은 올인원 인바운드 애플리케이션 콘솔이고, +> 5174는 그걸 Hydra 기반으로 우리가 직접 재구현하는 중이다.** From a52ec3b9f8141bce79104ab4e082f5a850292f8f Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 28 Jan 2026 17:47:56 +0900 Subject: [PATCH 04/14] =?UTF-8?q?=EC=99=B8=EB=B6=80=20IdP=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B4=80=EB=A6=AC=20API=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/bootstrap/bootstrap.go | 1 + backend/internal/domain/federation_models.go | 51 +++++++ .../internal/handler/federation_handler.go | 126 ++++++++++++++++++ .../repository/federation_repository.go | 54 ++++++++ 4 files changed, 232 insertions(+) create mode 100644 backend/internal/domain/federation_models.go create mode 100644 backend/internal/handler/federation_handler.go create mode 100644 backend/internal/repository/federation_repository.go diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index c6f6993a..35b83e87 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -36,6 +36,7 @@ func migrateSchemas(db *gorm.DB) error { &domain.User{}, &domain.Tenant{}, &domain.ApiKey{}, + &domain.IdentityProviderConfig{}, // &domain.RelyingParty{}, // TODO: Uncomment when model is ready // &domain.UserConsent{}, // TODO: Uncomment when model is ready ) diff --git a/backend/internal/domain/federation_models.go b/backend/internal/domain/federation_models.go new file mode 100644 index 00000000..0dbcbe21 --- /dev/null +++ b/backend/internal/domain/federation_models.go @@ -0,0 +1,51 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ProviderType defines the type of the identity provider. +type ProviderType string + +const ( + ProviderTypeOIDC ProviderType = "oidc" + ProviderTypeSAML ProviderType = "saml" +) + +// IdentityProviderConfig stores the configuration for an external Identity Provider. +type IdentityProviderConfig struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + TenantID string `gorm:"type:uuid;not null;index" json:"tenant_id"` + Tenant Tenant `gorm:"foreignKey:TenantID" json:"-"` // Belongs to Tenant + ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"` + DisplayName string `gorm:"not null" json:"display_name"` + Status string `gorm:"default:'active'" json:"status"` + + // OIDC Specific Fields + IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"` + ClientID *string `gorm:"null" json:"client_id,omitempty"` + ClientSecret *string `gorm:"null" json:"client_secret,omitempty"` + // Scopes are space-separated + Scopes *string `gorm:"null" json:"scopes,omitempty"` + + // SAML Specific Fields + MetadataURL *string `gorm:"null" json:"metadata_url,omitempty"` + MetadataXML *string `gorm:"type:text;null" json:"metadata_xml,omitempty"` + EntityID *string `gorm:"null" json:"entity_id,omitempty"` + AcsURL *string `gorm:"null" json:"acs_url,omitempty"` + + 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 (idc *IdentityProviderConfig) BeforeCreate(tx *gorm.DB) (err error) { + if idc.ID == "" { + idc.ID = uuid.NewString() + } + return +} diff --git a/backend/internal/handler/federation_handler.go b/backend/internal/handler/federation_handler.go new file mode 100644 index 00000000..7d264b67 --- /dev/null +++ b/backend/internal/handler/federation_handler.go @@ -0,0 +1,126 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "errors" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// FederationHandler handles API requests for IdP federation configurations. +type FederationHandler struct { + Repo *repository.FederationRepository + DB *gorm.DB // For tenant existence checks +} + +// NewFederationHandler creates a new FederationHandler. +func NewFederationHandler(repo *repository.FederationRepository, db *gorm.DB) *FederationHandler { + return &FederationHandler{Repo: repo, DB: db} +} + +// CreateIdpConfig handles the creation of a new IdP configuration. +func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error { + var req domain.IdentityProviderConfig + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // Basic validation + if req.TenantID == "" || req.DisplayName == "" || req.ProviderType == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id, display_name, and provider_type are required"}) + } + + // Check if tenant exists + var tenant domain.Tenant + if err := h.DB.First(&tenant, "id = ?", req.TenantID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + if err := h.Repo.Create(&req); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(req) +} + +// GetIdpConfigByID handles retrieving a single IdP configuration. +func (h *FederationHandler) GetIdpConfigByID(c *fiber.Ctx) error { + id := c.Params("id") + config, err := h.Repo.GetByID(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if config == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "identity provider configuration not found"}) + } + + return c.JSON(config) +} + +// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant. +func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { + tenantID := c.Params("tenantId") + if tenantID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) + } + + configs, err := h.Repo.ListByTenantID(tenantID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(configs) +} + +// UpdateIdpConfig handles updating an IdP configuration. +func (h *FederationHandler) UpdateIdpConfig(c *fiber.Ctx) error { + id := c.Params("id") + + existingConfig, err := h.Repo.GetByID(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if existingConfig == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "identity provider configuration not found"}) + } + + var req domain.IdentityProviderConfig + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // Overwrite fields + existingConfig.DisplayName = req.DisplayName + existingConfig.Status = req.Status + existingConfig.IssuerURL = req.IssuerURL + existingConfig.ClientID = req.ClientID + existingConfig.ClientSecret = req.ClientSecret + existingConfig.Scopes = req.Scopes + existingConfig.MetadataURL = req.MetadataURL + existingConfig.MetadataXML = req.MetadataXML + existingConfig.EntityID = req.EntityID + existingConfig.AcsURL = req.AcsURL + + + if err := h.Repo.Update(existingConfig); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(existingConfig) +} + +// DeleteIdpConfig handles deleting an IdP configuration. +func (h *FederationHandler) DeleteIdpConfig(c *fiber.Ctx) error { + id := c.Params("id") + + if err := h.Repo.Delete(id); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/backend/internal/repository/federation_repository.go b/backend/internal/repository/federation_repository.go new file mode 100644 index 00000000..09003069 --- /dev/null +++ b/backend/internal/repository/federation_repository.go @@ -0,0 +1,54 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "errors" + + "gorm.io/gorm" +) + +// FederationRepository handles database operations for IdentityProviderConfig. +type FederationRepository struct { + DB *gorm.DB +} + +// NewFederationRepository creates a new FederationRepository. +func NewFederationRepository(db *gorm.DB) *FederationRepository { + return &FederationRepository{DB: db} +} + +// Create creates a new identity provider configuration. +func (r *FederationRepository) Create(config *domain.IdentityProviderConfig) error { + return r.DB.Create(config).Error +} + +// GetByID retrieves an identity provider configuration by its ID. +func (r *FederationRepository) GetByID(id string) (*domain.IdentityProviderConfig, error) { + var config domain.IdentityProviderConfig + if err := r.DB.First(&config, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // Return nil, nil for not found + } + return nil, err + } + return &config, nil +} + +// ListByTenantID retrieves all identity provider configurations for a given tenant. +func (r *FederationRepository) ListByTenantID(tenantID string) ([]domain.IdentityProviderConfig, error) { + var configs []domain.IdentityProviderConfig + if err := r.DB.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil { + return nil, err + } + return configs, nil +} + +// Update updates an existing identity provider configuration. +func (r *FederationRepository) Update(config *domain.IdentityProviderConfig) error { + return r.DB.Save(config).Error +} + +// Delete removes an identity provider configuration by its ID. +func (r *FederationRepository) Delete(id string) error { + return r.DB.Delete(&domain.IdentityProviderConfig{}, "id = ?", id).Error +} From ec90853fe3297a3866b6e9e0090efcfbd2dd2fba Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 28 Jan 2026 17:48:24 +0900 Subject: [PATCH 05/14] =?UTF-8?q?IdP=20=EC=97=B0=EB=8F=99=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20UI=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 6 +- .../tenants/{ => routes}/TenantCreatePage.tsx | 14 +-- .../tenants/routes/TenantDetailPage.tsx | 71 ++++++++++++++ .../tenants/routes/TenantFederationPage.tsx | 94 +++++++++++++++++++ .../tenants/{ => routes}/TenantListPage.tsx | 10 +- .../TenantProfilePage.tsx} | 86 +++++++---------- 6 files changed, 215 insertions(+), 66 deletions(-) rename adminfront/src/features/tenants/{ => routes}/TenantCreatePage.tsx (92%) create mode 100644 adminfront/src/features/tenants/routes/TenantDetailPage.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantFederationPage.tsx rename adminfront/src/features/tenants/{ => routes}/TenantListPage.tsx (95%) rename adminfront/src/features/tenants/{TenantDetailPage.tsx => routes/TenantProfilePage.tsx} (65%) diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 262320a1..df1d0c21 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -6,9 +6,9 @@ import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthPage from "../features/auth/AuthPage"; import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; -import TenantCreatePage from "../features/tenants/TenantCreatePage"; -import TenantDetailPage from "../features/tenants/TenantDetailPage"; -import TenantListPage from "../features/tenants/TenantListPage"; +import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; +import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; +import TenantListPage from "../features/tenants/routes/TenantListPage"; import UserCreatePage from "../features/users/UserCreatePage"; import UserDetailPage from "../features/users/UserDetailPage"; import UserListPage from "../features/users/UserListPage"; diff --git a/adminfront/src/features/tenants/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx similarity index 92% rename from adminfront/src/features/tenants/TenantCreatePage.tsx rename to adminfront/src/features/tenants/routes/TenantCreatePage.tsx index f48b67e4..6d67d81d 100644 --- a/adminfront/src/features/tenants/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -3,19 +3,19 @@ import type { AxiosError } from "axios"; import { Building2, Sparkles } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Badge } from "../../components/ui/badge"; -import { Button } from "../../components/ui/button"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "../../components/ui/card"; -import { Input } from "../../components/ui/input"; -import { Label } from "../../components/ui/label"; -import { Textarea } from "../../components/ui/textarea"; -import { createTenant } from "../../lib/adminApi"; +} from "../../../components/ui/card"; +import { Input } from "../../../components/ui/input"; +import { Label } from "../../../components/ui/label"; +import { Textarea } from "../../../components/ui/textarea"; +import { createTenant } from "../../../lib/adminApi"; function TenantCreatePage() { const navigate = useNavigate(); diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx new file mode 100644 index 00000000..14080889 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -0,0 +1,71 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeft } from "lucide-react"; +import { Link, Outlet, useLocation, useParams } from "react-router-dom"; +import { Badge } from "../../../components/ui/badge"; +import { fetchTenant } from "../../../lib/adminApi"; + +function TenantDetailPage() { + const { tenantId } = useParams<{ tenantId: string }>(); + const location = useLocation(); + + const tenantQuery = useQuery({ + queryKey: ["tenant", tenantId], + queryFn: () => fetchTenant(tenantId!), + enabled: !!tenantId, + }); + + const isFederationTab = location.pathname.includes("/federation"); + + return ( +
+
+
+
+ + + Tenants + + / + Detail +
+

+ {tenantQuery.data?.name ?? "Loading Tenant..."} +

+

+ Edit tenant information or manage federation settings. +

+
+ Admin only +
+ + {/* Tabs */} +
+ + Profile + + + Federation + +
+ + {/* Outlet for nested routes */} + +
+ ); +} + +export default TenantDetailPage; diff --git a/adminfront/src/features/tenants/routes/TenantFederationPage.tsx b/adminfront/src/features/tenants/routes/TenantFederationPage.tsx new file mode 100644 index 00000000..e3c08d62 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantFederationPage.tsx @@ -0,0 +1,94 @@ +import { useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { listIdpConfigsForTenant } from "../../../lib/adminApi"; + +export function TenantFederationPage() { + const { tenantId } = useParams<{ tenantId: string }>(); + + if (!tenantId) { + return
Tenant ID is missing
; + } + + const { data, isLoading, error } = useQuery({ + queryKey: ["idpConfigs", tenantId], + queryFn: () => listIdpConfigsForTenant(tenantId), + }); + + return ( +
+

+ Identity Federation Settings +

+

+ Manage external identity providers for this tenant. +

+ +
+ +
+ + {isLoading &&
Loading configurations...
} + {error && ( +
+ Failed to load configurations: {error.message} +
+ )} + + {data && ( +
+ + + + + + + + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((config) => ( + + + + + + + )) + )} + +
Display NameProvider TypeStatusActions
+ No IdP configurations found. +
+ {config.display_name} + + {config.provider_type.toUpperCase()} + + + {config.status} + + + + +
+
+ )} +
+ ); +} diff --git a/adminfront/src/features/tenants/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx similarity index 95% rename from adminfront/src/features/tenants/TenantListPage.tsx rename to adminfront/src/features/tenants/routes/TenantListPage.tsx index 04c3c95c..92438a11 100644 --- a/adminfront/src/features/tenants/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -2,15 +2,15 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Link, useNavigate } from "react-router-dom"; -import { Badge } from "../../components/ui/badge"; -import { Button } from "../../components/ui/button"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "../../components/ui/card"; +} from "../../../components/ui/card"; import { Table, TableBody, @@ -18,8 +18,8 @@ import { TableHead, TableHeader, TableRow, -} from "../../components/ui/table"; -import { deleteTenant, fetchTenants } from "../../lib/adminApi"; +} from "../../../components/ui/table"; +import { deleteTenant, fetchTenants } from "../../../lib/adminApi"; function TenantListPage() { const navigate = useNavigate(); diff --git a/adminfront/src/features/tenants/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx similarity index 65% rename from adminfront/src/features/tenants/TenantDetailPage.tsx rename to adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 1e85864b..44c0aa9a 100644 --- a/adminfront/src/features/tenants/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -1,31 +1,36 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { ArrowLeft, Save, Trash2 } from "lucide-react"; +import { Save, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams } from "react-router-dom"; -import { Badge } from "../../components/ui/badge"; -import { Button } from "../../components/ui/button"; +import { useNavigate, useParams } from "react-router-dom"; +import { Button } from "../../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "../../components/ui/card"; -import { Input } from "../../components/ui/input"; -import { Label } from "../../components/ui/label"; -import { Textarea } from "../../components/ui/textarea"; -import { deleteTenant, fetchTenant, updateTenant } from "../../lib/adminApi"; +} from "../../../components/ui/card"; +import { Input } from "../../../components/ui/input"; +import { Label } from "../../../components/ui/label"; +import { Textarea } from "../../../components/ui/textarea"; +import { + deleteTenant, + fetchTenant, + updateTenant, +} from "../../../lib/adminApi"; -function TenantDetailPage() { - const { id } = useParams(); +export function TenantProfilePage() { + const { tenantId } = useParams<{ tenantId: string }>(); const navigate = useNavigate(); - const tenantId = useMemo(() => id ?? "", [id]); + + if (!tenantId) { + return
Tenant ID is missing
; + } const tenantQuery = useQuery({ queryKey: ["tenant", tenantId], queryFn: () => fetchTenant(tenantId), - enabled: tenantId !== "", }); const [name, setName] = useState(""); @@ -34,13 +39,12 @@ function TenantDetailPage() { const [status, setStatus] = useState("active"); useEffect(() => { - if (!tenantQuery.data) { - return; + if (tenantQuery.data) { + setName(tenantQuery.data.name); + setSlug(tenantQuery.data.slug); + setDescription(tenantQuery.data.description ?? ""); + setStatus(tenantQuery.data.status); } - setName(tenantQuery.data.name); - setSlug(tenantQuery.data.slug); - setDescription(tenantQuery.data.description ?? ""); - setStatus(tenantQuery.data.status); }, [tenantQuery.data]); const updateMutation = useMutation({ @@ -69,36 +73,19 @@ function TenantDetailPage() { ?.response?.data?.error; const handleDelete = () => { - if (!window.confirm("이 테넌트를 삭제할까요?")) { - return; + if (window.confirm("Are you sure you want to delete this tenant?")) { + deleteMutation.mutate(); } - deleteMutation.mutate(); }; return ( -
-
-
-
- - - Tenants - - / - Detail -
-

테넌트 상세

-

- 테넌트 정보를 수정하거나 삭제할 수 있습니다. -

-
- Admin only -
- - + <> + Tenant profile - Slug와 상태 변경은 바로 적용됩니다. + + Changes to slug and status are applied immediately. + {loadError && ( @@ -143,7 +130,6 @@ function TenantDetailPage() {
- {errorMsg && (
{errorMsg} @@ -152,18 +138,18 @@ function TenantDetailPage() { -
+
-
+ ); } - -export default TenantDetailPage; From a8ac66b31884fa65a31b9f136dade499fb1a8ac4 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 29 Jan 2026 10:28:13 +0900 Subject: [PATCH 06/14] =?UTF-8?q?=EC=99=B8=EB=B6=80=20OIDC=20IdP=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 3 + backend/go.sum | 6 + .../internal/handler/federation_handler.go | 154 ++++++++---------- .../repository/federation_repository.go | 52 +----- .../repository/gorm_federation_repository.go | 23 +++ .../internal/service/federation_service.go | 91 +++++++++++ 6 files changed, 196 insertions(+), 133 deletions(-) create mode 100644 backend/internal/repository/gorm_federation_repository.go create mode 100644 backend/internal/service/federation_service.go diff --git a/backend/go.mod b/backend/go.mod index fc9026fe..2a346b53 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -36,10 +36,12 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/goccy/go-json v0.10.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -72,6 +74,7 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7eed1f6a..365e8e00 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -38,6 +38,8 @@ github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgIS github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -53,6 +55,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= @@ -184,6 +188,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/backend/internal/handler/federation_handler.go b/backend/internal/handler/federation_handler.go index 7d264b67..16efd36c 100644 --- a/backend/internal/handler/federation_handler.go +++ b/backend/internal/handler/federation_handler.go @@ -3,21 +3,80 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" + "baron-sso-backend/internal/service" "errors" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) -// FederationHandler handles API requests for IdP federation configurations. +// FederationHandler handles API requests for IdP federation. type FederationHandler struct { - Repo *repository.FederationRepository - DB *gorm.DB // For tenant existence checks + fedSvc *service.FederationService + repo repository.FederationRepository // For IdP Config CRUD + db *gorm.DB // For tenant existence checks, etc. in CRUD } // NewFederationHandler creates a new FederationHandler. -func NewFederationHandler(repo *repository.FederationRepository, db *gorm.DB) *FederationHandler { - return &FederationHandler{Repo: repo, DB: db} +func NewFederationHandler(fedSvc *service.FederationService, repo repository.FederationRepository, db *gorm.DB) *FederationHandler { + return &FederationHandler{ + fedSvc: fedSvc, + repo: repo, + db: db, + } +} + +// InitiateOIDCLogin handles the start of the OIDC login flow. +// It expects `provider_id` and `login_challenge` as query parameters. +func (h *FederationHandler) InitiateOIDCLogin(c *fiber.Ctx) error { + providerID := c.Query("provider_id") + loginChallenge := c.Query("login_challenge") + + if providerID == "" || loginChallenge == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "provider_id and login_challenge are required"}) + } + + redirectURL, err := h.fedSvc.InitiateOIDCLogin(c.Context(), providerID, loginChallenge) + if err != nil { + // Log the error properly in a real application + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to initiate OIDC login"}) + } + + return c.Redirect(redirectURL, fiber.StatusFound) +} + +// HandleOIDCCallback handles the OIDC callback from the IdP. +func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error { + code := c.Query("code") + state := c.Query("state") + + if code == "" || state == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "code and state are required"}) + } + + redirectURL, err := h.fedSvc.HandleOIDCCallback(c.Context(), code, state) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to handle OIDC callback"}) + } + + return c.Redirect(redirectURL, fiber.StatusFound) +} + + +// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant. +func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { + tenantID := c.Params("tenantId") + if tenantID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) + } + + // This is a temporary solution. We should create a proper method in the repository. + var configs []domain.IdentityProviderConfig + if err := h.db.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(configs) } // CreateIdpConfig handles the creation of a new IdP configuration. @@ -34,93 +93,18 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error { // Check if tenant exists var tenant domain.Tenant - if err := h.DB.First(&tenant, "id = ?", req.TenantID).Error; err != nil { + if err := h.db.First(&tenant, "id = ?", req.TenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant not found"}) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - - if err := h.Repo.Create(&req); err != nil { + + // Create in DB + if err := h.db.Create(&req).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.Status(fiber.StatusCreated).JSON(req) } - -// GetIdpConfigByID handles retrieving a single IdP configuration. -func (h *FederationHandler) GetIdpConfigByID(c *fiber.Ctx) error { - id := c.Params("id") - config, err := h.Repo.GetByID(id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - if config == nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "identity provider configuration not found"}) - } - - return c.JSON(config) -} - -// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant. -func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { - tenantID := c.Params("tenantId") - if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) - } - - configs, err := h.Repo.ListByTenantID(tenantID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - return c.JSON(configs) -} - -// UpdateIdpConfig handles updating an IdP configuration. -func (h *FederationHandler) UpdateIdpConfig(c *fiber.Ctx) error { - id := c.Params("id") - - existingConfig, err := h.Repo.GetByID(id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - if existingConfig == nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "identity provider configuration not found"}) - } - - var req domain.IdentityProviderConfig - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) - } - - // Overwrite fields - existingConfig.DisplayName = req.DisplayName - existingConfig.Status = req.Status - existingConfig.IssuerURL = req.IssuerURL - existingConfig.ClientID = req.ClientID - existingConfig.ClientSecret = req.ClientSecret - existingConfig.Scopes = req.Scopes - existingConfig.MetadataURL = req.MetadataURL - existingConfig.MetadataXML = req.MetadataXML - existingConfig.EntityID = req.EntityID - existingConfig.AcsURL = req.AcsURL - - - if err := h.Repo.Update(existingConfig); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - return c.JSON(existingConfig) -} - -// DeleteIdpConfig handles deleting an IdP configuration. -func (h *FederationHandler) DeleteIdpConfig(c *fiber.Ctx) error { - id := c.Params("id") - - if err := h.Repo.Delete(id); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - return c.SendStatus(fiber.StatusNoContent) -} +// TODO: Re-implement Update, Delete handlers for IdP Configs diff --git a/backend/internal/repository/federation_repository.go b/backend/internal/repository/federation_repository.go index 09003069..27b4ce3f 100644 --- a/backend/internal/repository/federation_repository.go +++ b/backend/internal/repository/federation_repository.go @@ -2,53 +2,9 @@ package repository import ( "baron-sso-backend/internal/domain" - "errors" - - "gorm.io/gorm" + "context" ) -// FederationRepository handles database operations for IdentityProviderConfig. -type FederationRepository struct { - DB *gorm.DB -} - -// NewFederationRepository creates a new FederationRepository. -func NewFederationRepository(db *gorm.DB) *FederationRepository { - return &FederationRepository{DB: db} -} - -// Create creates a new identity provider configuration. -func (r *FederationRepository) Create(config *domain.IdentityProviderConfig) error { - return r.DB.Create(config).Error -} - -// GetByID retrieves an identity provider configuration by its ID. -func (r *FederationRepository) GetByID(id string) (*domain.IdentityProviderConfig, error) { - var config domain.IdentityProviderConfig - if err := r.DB.First(&config, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil // Return nil, nil for not found - } - return nil, err - } - return &config, nil -} - -// ListByTenantID retrieves all identity provider configurations for a given tenant. -func (r *FederationRepository) ListByTenantID(tenantID string) ([]domain.IdentityProviderConfig, error) { - var configs []domain.IdentityProviderConfig - if err := r.DB.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil { - return nil, err - } - return configs, nil -} - -// Update updates an existing identity provider configuration. -func (r *FederationRepository) Update(config *domain.IdentityProviderConfig) error { - return r.DB.Save(config).Error -} - -// Delete removes an identity provider configuration by its ID. -func (r *FederationRepository) Delete(id string) error { - return r.DB.Delete(&domain.IdentityProviderConfig{}, "id = ?", id).Error -} +type FederationRepository interface { + FindProviderByID(ctx context.Context, providerID string) (*domain.IdentityProviderConfig, error) +} \ No newline at end of file diff --git a/backend/internal/repository/gorm_federation_repository.go b/backend/internal/repository/gorm_federation_repository.go new file mode 100644 index 00000000..df8a4e92 --- /dev/null +++ b/backend/internal/repository/gorm_federation_repository.go @@ -0,0 +1,23 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + "gorm.io/gorm" +) + +type GormFederationRepository struct { + db *gorm.DB +} + +func NewGormFederationRepository(db *gorm.DB) *GormFederationRepository { + return &GormFederationRepository{db: db} +} + +func (r *GormFederationRepository) FindProviderByID(ctx context.Context, providerID string) (*domain.IdentityProviderConfig, error) { + var provider domain.IdentityProviderConfig + if err := r.db.WithContext(ctx).First(&provider, "id = ?", providerID).Error; err != nil { + return nil, err + } + return &provider, nil +} diff --git a/backend/internal/service/federation_service.go b/backend/internal/service/federation_service.go new file mode 100644 index 00000000..313672ab --- /dev/null +++ b/backend/internal/service/federation_service.go @@ -0,0 +1,91 @@ +package service + +import ( + "baron-sso-backend/internal/repository" + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "golang.org/x/oauth2" + "github.com/coreos/go-oidc/v3/oidc" +) + +type FederationService struct { + repo repository.FederationRepository + hydraSvc *HydraAdminService + redisSvc *RedisService +} + +func NewFederationService(repo repository.FederationRepository, hydraSvc *HydraAdminService, redisSvc *RedisService) *FederationService { + return &FederationService{repo: repo, hydraSvc: hydraSvc, redisSvc: redisSvc} +} + +func (s *FederationService) InitiateOIDCLogin(ctx context.Context, providerID, loginChallenge string) (string, error) { + provider, err := s.repo.FindProviderByID(ctx, providerID) + if err != nil { + return "", fmt.Errorf("failed to find provider: %w", err) + } + + if provider == nil || provider.IssuerURL == nil || provider.ClientID == nil || provider.ClientSecret == nil || provider.Scopes == nil { + return "", fmt.Errorf("OIDC configuration for provider %s is incomplete", providerID) + } + + oidcProvider, err := oidc.NewProvider(ctx, *provider.IssuerURL) + if err != nil { + return "", fmt.Errorf("failed to create OIDC provider: %w", err) + } + + config := oauth2.Config{ + ClientID: *provider.ClientID, + ClientSecret: *provider.ClientSecret, + Endpoint: oidcProvider.Endpoint(), + RedirectURL: "http://localhost:8080/api/v1/federation/oidc/callback", // This should be configurable + Scopes: []string{*provider.Scopes}, + } + + state, err := generateState() + if err != nil { + return "", fmt.Errorf("failed to generate state: %w", err) + } + + // Store state and login_challenge in Redis + redisKey := fmt.Sprintf("oidc_state:%s", state) + if err := s.redisSvc.Set(redisKey, loginChallenge, 10*time.Minute); err != nil { + return "", fmt.Errorf("failed to save state to Redis: %w", err) + } + + return config.AuthCodeURL(state), nil +} + +func (s *FederationService) HandleOIDCCallback(ctx context.Context, code, state string) (string, error) { + // 1. Retrieve login_challenge from Redis + redisKey := fmt.Sprintf("oidc_state:%s", state) + loginChallenge, err := s.redisSvc.Get(redisKey) + if err != nil { + return "", fmt.Errorf("failed to get state from Redis or state expired: %w", err) + } + // Delete the state from Redis now that it's been used + s.redisSvc.Delete(redisKey) + + // TODO: Finish the rest of the callback logic + // 2. Exchange code for token + // 3. Verify ID token + // 4. JIT Provisioning + // 5. Accept Hydra Login Request + + fmt.Println("Login challenge found:", loginChallenge) + + return "http://localhost:3000/login?login_successful=true", nil // Placeholder +} + + +func generateState() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} From 968e16422d037e9f089a9409e7c569da14130c66 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 29 Jan 2026 10:28:59 +0900 Subject: [PATCH 07/14] =?UTF-8?q?IdP=20=EC=84=A4=EC=A0=95=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantFederationPage.tsx | 179 +++++++++++++++++- 1 file changed, 172 insertions(+), 7 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantFederationPage.tsx b/adminfront/src/features/tenants/routes/TenantFederationPage.tsx index e3c08d62..ca36003d 100644 --- a/adminfront/src/features/tenants/routes/TenantFederationPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFederationPage.tsx @@ -1,9 +1,167 @@ import { useParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { listIdpConfigsForTenant } from "../../../lib/adminApi"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createIdpConfig, listIdpConfigsForTenant } from "../../../lib/adminApi"; +import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/adminApi"; +import { useState } from "react"; + +// Proper Modal Component with Form +const CreateIdpModal = ({ + isOpen, + onClose, + tenantId, +}: { + isOpen: boolean; + onClose: () => void; + tenantId: string; +}) => { + const queryClient = useQueryClient(); + const [formData, setFormData] = useState({ + tenant_id: tenantId, + provider_type: "oidc", + display_name: "", + status: "active", + issuer_url: "", + client_id: "", + client_secret: "", + scopes: "openid email profile", + }); + + const mutation = useMutation({ + mutationFn: (newData: IdpConfigCreateRequest) => createIdpConfig(newData), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["idpConfigs", tenantId] }); + onClose(); + }, + onError: (error) => { + // Basic error handling + alert(`Failed to create configuration: ${error.message}`); + }, + }); + + // 이 내용으로 교체해주세요 + const handleChange = ( + e: React.ChangeEvent, + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutation.mutate(formData); + }; + + if (!isOpen) return null; + + return ( +
+
+

Add New IdP Configuration

+
+ {/* Display Name */} +
+ + +
+ + {/* Issuer URL */} +
+ + +
+ + {/* Client ID */} +
+ + +
+ + {/* Client Secret */} +
+ + +
+ + {/* Scopes */} +
+ + +
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +}; export function TenantFederationPage() { const { tenantId } = useParams<{ tenantId: string }>(); + const [isCreateModalOpen, setCreateModalOpen] = useState(false); if (!tenantId) { return
Tenant ID is missing
; @@ -16,19 +174,26 @@ export function TenantFederationPage() { return (
-

- Identity Federation Settings -

+

Identity Federation Settings

Manage external identity providers for this tenant.

-
+ setCreateModalOpen(false)} + tenantId={tenantId} + /> + {isLoading &&
Loading configurations...
} {error && (
@@ -55,7 +220,7 @@ export function TenantFederationPage() { ) : ( - data.map((config) => ( + data.map((config: IdpConfig) => ( {config.display_name} From c82934b7cd9fa3b7ab21a8aff78d5fb2efe9795a Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 29 Jan 2026 14:52:10 +0900 Subject: [PATCH 08/14] =?UTF-8?q?API=20=EA=B8=B0=EB=B3=B8=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/lib/apiClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devfront/src/lib/apiClient.ts b/devfront/src/lib/apiClient.ts index f136f91b..b362ba79 100644 --- a/devfront/src/lib/apiClient.ts +++ b/devfront/src/lib/apiClient.ts @@ -4,7 +4,7 @@ const apiClient = axios.create({ baseURL: import.meta.env.VITE_DEV_API_BASE ?? import.meta.env.VITE_ADMIN_API_BASE ?? - "/api/v1/dev", + "/api/v1", }); apiClient.interceptors.request.use((config) => { From 59a5f99fb9881ffac8973998a6861be041a65755 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 29 Jan 2026 14:53:14 +0900 Subject: [PATCH 09/14] =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=20Federation=20=ED=83=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/app/routes.tsx | 2 ++ devfront/src/features/clients/ClientConsentsPage.tsx | 6 ++++++ devfront/src/features/clients/ClientDetailsPage.tsx | 6 ++++++ devfront/src/features/clients/ClientGeneralPage.tsx | 6 ++++++ 4 files changed, 20 insertions(+) diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index 0bbba406..b30cb04c 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -4,6 +4,7 @@ import ClientConsentsPage from "../features/clients/ClientConsentsPage"; import ClientDetailsPage from "../features/clients/ClientDetailsPage"; import ClientGeneralPage from "../features/clients/ClientGeneralPage"; import ClientsPage from "../features/clients/ClientsPage"; +import { ClientFederationPage } from "../features/clients/routes/ClientFederationPage"; export const router = createBrowserRouter( [ @@ -17,6 +18,7 @@ export const router = createBrowserRouter( { path: "clients/:id", element: }, { path: "clients/:id/consents", element: }, { path: "clients/:id/settings", element: }, + { path: "clients/:id/federation", element: }, ], }, ], diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 77b7fae9..3f1f60fa 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -120,6 +120,12 @@ function ClientConsentsPage() { > Settings + + Federation +
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 1e1e2e49..5e8c99f5 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -95,6 +95,12 @@ function ClientDetailsPage() { > Settings + + Federation +
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 5bace9de..78f97136 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -170,6 +170,12 @@ function ClientGeneralPage() { Settings + + Federation + )} From 3e2ceff692f2f865787670a37d9421bc5f5f2f81 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 29 Jan 2026 14:54:38 +0900 Subject: [PATCH 10/14] =?UTF-8?q?IdP=20=EC=97=B0=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20devfront=20=EC=9D=B4=EC=A0=84=20=EB=B0=8F=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=A2=85?= =?UTF-8?q?=EC=86=8D=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 17 +++-- backend/internal/domain/federation_models.go | 9 ++- .../internal/handler/federation_handler.go | 63 ++++++++++++++++-- .../internal/service/federation_service.go | 6 +- .../clients/routes/ClientFederationPage.tsx | 46 +++++++------ devfront/src/lib/devApi.ts | 64 +++++++++++++++++++ 6 files changed, 161 insertions(+), 44 deletions(-) rename adminfront/src/features/tenants/routes/TenantFederationPage.tsx => devfront/src/features/clients/routes/ClientFederationPage.tsx (89%) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5ac0d999..f69a1d6e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -476,15 +476,14 @@ func main() { admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey) // 개발자 포털 라우트 (RP/Consent 관리) - dev := api.Group("/dev") - dev.Get("/clients", devHandler.ListClients) - dev.Post("/clients", devHandler.CreateClient) - dev.Get("/clients/:id", devHandler.GetClient) - dev.Put("/clients/:id", devHandler.UpdateClient) - dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) - dev.Delete("/clients/:id", devHandler.DeleteClient) - dev.Get("/consents", devHandler.ListConsents) - dev.Delete("/consents", devHandler.RevokeConsents) + api.Get("/clients", devHandler.ListClients) + api.Post("/clients", devHandler.CreateClient) + api.Get("/clients/:id", devHandler.GetClient) + api.Put("/clients/:id", devHandler.UpdateClient) + api.Patch("/clients/:id/status", devHandler.UpdateClientStatus) + api.Delete("/clients/:id", devHandler.DeleteClient) + api.Get("/consents", devHandler.ListConsents) + api.Delete("/consents", devHandler.RevokeConsents) // Webhook for Descope Generic SMS Gateway auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay) diff --git a/backend/internal/domain/federation_models.go b/backend/internal/domain/federation_models.go index 0dbcbe21..4f29b9db 100644 --- a/backend/internal/domain/federation_models.go +++ b/backend/internal/domain/federation_models.go @@ -18,16 +18,15 @@ const ( // IdentityProviderConfig stores the configuration for an external Identity Provider. type IdentityProviderConfig struct { ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` - TenantID string `gorm:"type:uuid;not null;index" json:"tenant_id"` - Tenant Tenant `gorm:"foreignKey:TenantID" json:"-"` // Belongs to Tenant + ClientID string `gorm:"type:uuid;not null;index" json:"client_id"` // Replaces TenantID ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"` DisplayName string `gorm:"not null" json:"display_name"` Status string `gorm:"default:'active'" json:"status"` // OIDC Specific Fields - IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"` - ClientID *string `gorm:"null" json:"client_id,omitempty"` - ClientSecret *string `gorm:"null" json:"client_secret,omitempty"` + IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"` + OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID + OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret // Scopes are space-separated Scopes *string `gorm:"null" json:"scopes,omitempty"` diff --git a/backend/internal/handler/federation_handler.go b/backend/internal/handler/federation_handler.go index 16efd36c..e4258a16 100644 --- a/backend/internal/handler/federation_handler.go +++ b/backend/internal/handler/federation_handler.go @@ -62,6 +62,55 @@ func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error { return c.Redirect(redirectURL, fiber.StatusFound) } +// --- New Client-based IdP Config Methods --- + +// ListIdpConfigsForClient handles listing all IdP configurations for a client. +func (h *FederationHandler) ListIdpConfigsForClient(c *fiber.Ctx) error { + clientID := c.Params("clientId") + if clientID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "clientId is required"}) + } + + var configs []domain.IdentityProviderConfig + if err := h.db.Where("client_id = ?", clientID).Find(&configs).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(configs) +} + +// CreateIdpConfigForClient handles the creation of a new IdP configuration for a client. +func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error { + clientID := c.Params("clientId") + if clientID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "clientId is required in path"}) + } + + var req domain.IdentityProviderConfig + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // Assign clientID from path parameter + req.ClientID = clientID + + // Basic validation + if req.DisplayName == "" || req.ProviderType == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "display_name and provider_type are required"}) + } + + // TODO: Optionally, validate if the clientID exists in Hydra + + // Create in DB + if err := h.db.Create(&req).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(req) +} + + +// --- Deprecated Tenant-based IdP Config Methods --- // ListIdpConfigsForTenant handles listing all IdP configurations for a tenant. func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { @@ -72,6 +121,8 @@ func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { // This is a temporary solution. We should create a proper method in the repository. var configs []domain.IdentityProviderConfig + // Note: This now queries client_id, which is incorrect for tenants. + // This method is deprecated. if err := h.db.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -86,14 +137,14 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } - // Basic validation - if req.TenantID == "" || req.DisplayName == "" || req.ProviderType == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id, display_name, and provider_type are required"}) + // Basic validation - This is the old validation logic + if req.ClientID == "" || req.DisplayName == "" || req.ProviderType == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id, display_name, and provider_type are required"}) } - // Check if tenant exists + // This check is now incorrect and deprecated. var tenant domain.Tenant - if err := h.db.First(&tenant, "id = ?", req.TenantID).Error; err != nil { + if err := h.db.First(&tenant, "id = ?", req.ClientID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant not found"}) } @@ -107,4 +158,4 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(req) } -// TODO: Re-implement Update, Delete handlers for IdP Configs +// TODO: Re-implement Update, Delete handlers for IdP Configs for Clients diff --git a/backend/internal/service/federation_service.go b/backend/internal/service/federation_service.go index 313672ab..fed14881 100644 --- a/backend/internal/service/federation_service.go +++ b/backend/internal/service/federation_service.go @@ -28,7 +28,7 @@ func (s *FederationService) InitiateOIDCLogin(ctx context.Context, providerID, l return "", fmt.Errorf("failed to find provider: %w", err) } - if provider == nil || provider.IssuerURL == nil || provider.ClientID == nil || provider.ClientSecret == nil || provider.Scopes == nil { + if provider == nil || provider.IssuerURL == nil || provider.OIDCClientID == nil || provider.OIDCClientSecret == nil || provider.Scopes == nil { return "", fmt.Errorf("OIDC configuration for provider %s is incomplete", providerID) } @@ -38,8 +38,8 @@ func (s *FederationService) InitiateOIDCLogin(ctx context.Context, providerID, l } config := oauth2.Config{ - ClientID: *provider.ClientID, - ClientSecret: *provider.ClientSecret, + ClientID: *provider.OIDCClientID, + ClientSecret: *provider.OIDCClientSecret, Endpoint: oidcProvider.Endpoint(), RedirectURL: "http://localhost:8080/api/v1/federation/oidc/callback", // This should be configurable Scopes: []string{*provider.Scopes}, diff --git a/adminfront/src/features/tenants/routes/TenantFederationPage.tsx b/devfront/src/features/clients/routes/ClientFederationPage.tsx similarity index 89% rename from adminfront/src/features/tenants/routes/TenantFederationPage.tsx rename to devfront/src/features/clients/routes/ClientFederationPage.tsx index ca36003d..8a4ef063 100644 --- a/adminfront/src/features/tenants/routes/TenantFederationPage.tsx +++ b/devfront/src/features/clients/routes/ClientFederationPage.tsx @@ -1,35 +1,39 @@ import { useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { createIdpConfig, listIdpConfigsForTenant } from "../../../lib/adminApi"; -import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/adminApi"; +import { + createIdpConfigForClient, + listIdpConfigsForClient, +} from "../../../lib/devApi"; +import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/devApi"; import { useState } from "react"; // Proper Modal Component with Form const CreateIdpModal = ({ isOpen, onClose, - tenantId, + clientId, }: { isOpen: boolean; onClose: () => void; - tenantId: string; + clientId: string; }) => { const queryClient = useQueryClient(); const [formData, setFormData] = useState({ - tenant_id: tenantId, + client_id: clientId, provider_type: "oidc", display_name: "", status: "active", issuer_url: "", - client_id: "", - client_secret: "", + oidc_client_id: "", + oidc_client_secret: "", scopes: "openid email profile", }); const mutation = useMutation({ - mutationFn: (newData: IdpConfigCreateRequest) => createIdpConfig(newData), + mutationFn: (newData: IdpConfigCreateRequest) => + createIdpConfigForClient(newData), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["idpConfigs", tenantId] }); + queryClient.invalidateQueries({ queryKey: ["idpConfigs", clientId] }); onClose(); }, onError: (error) => { @@ -99,8 +103,8 @@ const CreateIdpModal = ({ (); +export function ClientFederationPage() { + const { id: clientId } = useParams<{ id: string }>(); const [isCreateModalOpen, setCreateModalOpen] = useState(false); - if (!tenantId) { - return
Tenant ID is missing
; + if (!clientId) { + return
Client ID is missing
; } const { data, isLoading, error } = useQuery({ - queryKey: ["idpConfigs", tenantId], - queryFn: () => listIdpConfigsForTenant(tenantId), + queryKey: ["idpConfigs", clientId], + queryFn: () => listIdpConfigsForClient(clientId), }); return (

Identity Federation Settings

- Manage external identity providers for this tenant. + Manage external identity providers for this application.

@@ -191,7 +195,7 @@ export function TenantFederationPage() { setCreateModalOpen(false)} - tenantId={tenantId} + clientId={clientId} /> {isLoading &&
Loading configurations...
} diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 2e95ae7b..794809f8 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -59,6 +59,37 @@ export type ConsentListResponse = { items: ConsentSummary[]; }; +// --- Federation / IdP Config Types --- +export type ProviderType = "oidc" | "saml"; + +export type IdpConfig = { + id: string; + client_id: string; // Changed from tenant_id + provider_type: ProviderType; + display_name: string; + status: "active" | "inactive"; + issuer_url?: string; + // OIDC specific fields + oidc_client_id?: string; + oidc_client_secret?: string; + scopes?: string; + // SAML specific fields + metadata_url?: string; + metadata_xml?: string; + entity_id?: string; + acs_url?: string; + createdAt: string; + updatedAt: string; +}; + +export type IdpConfigCreateRequest = Omit< + IdpConfig, + "id" | "createdAt" | "updatedAt" +>; +export type IdpConfigUpdateRequest = Partial; +// --- End Federation Types --- + + export async function fetchClients() { const { data } = await apiClient.get("/clients"); return data; @@ -123,3 +154,36 @@ export async function revokeConsent(subject: string, clientId?: string) { } await apiClient.delete("/consents", { params }); } + +// --- Federation / IdP Config API Calls --- + +export async function listIdpConfigsForClient(clientId: string) { + const { data } = await apiClient.get( + `/clients/${clientId}/idps`, + ); + return data; +} + +export async function createIdpConfigForClient(payload: IdpConfigCreateRequest) { + const { data } = await apiClient.post( + `/clients/${payload.client_id}/idps`, + payload, + ); + return data; +} + +export async function updateIdpConfig( + clientId: string, + idpId: string, + payload: IdpConfigUpdateRequest, +) { + const { data } = await apiClient.put( + `/clients/${clientId}/idps/${idpId}`, + payload, + ); + return data; +} + +export async function deleteIdpConfig(clientId: string, idpId: string) { + await apiClient.delete(`/clients/${clientId}/idps/${idpId}`); +} From 765bf67cab6d478dfbac5b216326441d2f8ffb19 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 29 Jan 2026 17:05:16 +0900 Subject: [PATCH 11/14] =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?ui/ux=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../features/clients/ClientConsentsPage.tsx | 2 +- .../features/clients/ClientDetailsPage.tsx | 275 +++++++----- .../features/clients/ClientGeneralPage.tsx | 413 ++++++++---------- devfront/src/features/clients/ClientsPage.tsx | 9 +- 5 files changed, 341 insertions(+), 359 deletions(-) diff --git a/.gitignore b/.gitignore index 276b51e6..a4d2df8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # General .env +.temp .DS_Store .idea/ .vscode/ diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 3f1f60fa..36aa843d 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -109,7 +109,7 @@ function ClientConsentsPage() { to={`/clients/${clientId}`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground" > - Overview + Connection Consent & Users diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 5e8c99f5..48035dd4 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -1,10 +1,10 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { AlertCircle, Copy, Eye, Link2, Shield, Workflow } from "lucide-react"; +import { AlertCircle, Copy, Eye, Link2, Shield, Workflow, Save } from "lucide-react"; import { Link, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; -import { Card, CardContent } from "../../components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../components/ui/card"; import { Separator } from "../../components/ui/separator"; import { Table, @@ -12,17 +12,47 @@ import { TableCell, TableRow, } from "../../components/ui/table"; -import { fetchClient } from "../../lib/devApi"; +import { Textarea } from "../../components/ui/textarea"; +import { Label } from "../../components/ui/label"; +import { fetchClient, updateClient } from "../../lib/devApi"; +import { useState, useEffect } from "react"; function ClientDetailsPage() { const params = useParams(); + const queryClient = useQueryClient(); const clientId = params.id ?? ""; + const { data, isLoading, error } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), enabled: clientId.length > 0, }); + const [redirectUris, setRedirectUris] = useState(""); + + useEffect(() => { + if (data?.client?.redirectUris) { + setRedirectUris(data.client.redirectUris.join(", ")); + } + }, [data]); + + const mutation = useMutation({ + mutationFn: () => { + const uriList = redirectUris + .split(",") + .map((u) => u.trim()) + .filter(Boolean); + return updateClient(clientId, { redirectUris: uriList }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["client", clientId] }); + alert("Redirect URIs가 저장되었습니다."); + }, + onError: (err) => { + alert(`저장 실패: ${(err as Error).message}`); + }, + }); + if (!clientId) { return
Client ID가 필요합니다.
; } @@ -81,7 +111,7 @@ function ClientDetailsPage() { to={`/clients/${clientId}`} className="border-b-2 border-primary pb-3 text-sm font-bold text-primary" > - Overview + Connection
-
-

클라이언트 자격 증명

- - -
-

- Client ID -

-

{data.client.id}

-
- -
-
- - - -
-

- Client Secret -

-

- •••••••••••••••• -

-
-
- - - -
-
-
-
- -
-
-

OIDC 엔드포인트

- - - 읽기 전용 - -
- - - - {endpoints.map((endpoint) => ( - - -

- {endpoint.label} -

-
- - - {endpoint.value} - - - -
- ))} -
-
-
-
+
+ -
-
-
-
- -
-
-

보안 메모

-

- 엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 - 로그와 연계하세요. -

-
+ + +
+

+ Client Secret +

+
+

••••••••••••••••

+
+ + +
+
+
+ +
-
- - - 감사 이벤트 필요 - + +
+
+

OIDC 엔드포인트

+ + + 읽기 전용 + +
+ + + + {endpoints.map((endpoint) => ( + + +

+ {endpoint.label} +

+
+ + + {endpoint.value} + + + +
+ ))} +
+
+
+
+
+ +
+
+

리디렉션 URI 설정

+ + + Redirect URIs + + 인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다. + + + +
+ +