forked from baron/baron-sso
headless login으로 리펙토링
This commit is contained in:
2
Makefile
2
Makefile
@@ -62,7 +62,7 @@ up-front-dev: up-infra up-ory up-backend
|
||||
@echo "Dev stack is up (infra + ory + backend)."
|
||||
|
||||
# --- 종료 (Down) ---
|
||||
down-all:
|
||||
down:
|
||||
@echo "Stopping ALL stacks (infra + ory + app)..."
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
||||
|
||||
|
||||
140
README.md
140
README.md
@@ -2,13 +2,27 @@
|
||||
|
||||
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||
|
||||
## 버그 대응 대원칙 (필수)
|
||||
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
|
||||
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
|
||||
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
|
||||
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
|
||||
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
|
||||
## 📂 프로젝트 구조 (Project Structure)
|
||||
|
||||
```
|
||||
baron_sso/
|
||||
├── backend/ # Go Fiber 애플리케이션
|
||||
│ ├── cmd/server/ # 진입점 (Entry point)
|
||||
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
|
||||
│ └── Dockerfile
|
||||
├── userfront/ # Flutter 애플리케이션
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── adminfront/ # React 기반 관리
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
|
||||
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── docker-compose.yaml # 앱 서비스 (Front, Back)
|
||||
├── .env.sample # 환경 설정 템플릿
|
||||
└── README.md # 본 파일
|
||||
```
|
||||
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
|
||||
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
|
||||
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
|
||||
@@ -20,6 +34,7 @@
|
||||
* AdminFront: 사용자 관리 등 Admin 기능
|
||||
* DevFront: RP 관리 등 개발자 기능
|
||||
|
||||
|
||||
## 🏗 아키텍처 (Architecture)
|
||||
|
||||
### 0. Ory Stack
|
||||
@@ -88,6 +103,85 @@ flowchart
|
||||
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
|
||||
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환
|
||||
|
||||
### 5. Headless Login ID/Password Flow
|
||||
- 목적: headless login을 허용한 클라이언트가 자체 로그인 화면에서 `ID/password`를 수집하되, Baron Backend가 OIDC 로그인 흐름만 계속 진행하고 RP에는 `sessionJwt`를 직접 넘기지 않습니다.
|
||||
- 대상 엔드포인트: `POST /api/v1/auth/headless/password/login`
|
||||
- 관련 구현:
|
||||
- `backend/internal/handler/auth_handler.go`
|
||||
- `backend/internal/domain/hydra_models.go`
|
||||
- `backend/internal/handler/auth_handler_login_test.go`
|
||||
|
||||
#### 호출 순서
|
||||
1. RP 브라우저가 Hydra Public의 `/oauth2/auth`를 호출해 OIDC 인증을 시작합니다.
|
||||
2. Hydra가 로그인 단계로 넘긴 `login_challenge`를 RP가 확보합니다.
|
||||
3. RP backend가 자기 private key로 `client_assertion` JWT를 서명합니다.
|
||||
4. RP backend가 Baron Backend의 `POST /api/v1/auth/headless/password/login`에 `client_id`, `client_assertion`, `login_challenge`, `loginId`, `password`를 전송합니다.
|
||||
5. Baron Backend가 Hydra login request와 RP 설정을 검증한 뒤 Kratos sign-in 및 Hydra login accept를 수행합니다.
|
||||
6. 성공 시 Baron Backend는 `redirectTo`만 반환하고, RP 브라우저는 그 URL로 이동해 OIDC 흐름을 이어갑니다.
|
||||
|
||||
#### 요청 바디
|
||||
```json
|
||||
{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": "<signed-jwt>",
|
||||
"login_challenge": "<hydra-login-challenge>",
|
||||
"loginId": "employee001",
|
||||
"password": "secret"
|
||||
}
|
||||
```
|
||||
|
||||
#### 성공 응답
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"provider": "ory",
|
||||
"redirectTo": "https://rp.example.com/callback?code=..."
|
||||
}
|
||||
```
|
||||
|
||||
#### RP / Hydra 선행 조건
|
||||
- Hydra login request의 `client.client_id`와 요청 바디의 `client_id`가 반드시 같아야 합니다.
|
||||
- client가 headless login 선행 조건을 만족해야 합니다.
|
||||
- `headless_token_endpoint_auth_method == "private_key_jwt"` 또는 top-level `token_endpoint_auth_method == "private_key_jwt"`
|
||||
- `headless_jwks_uri` 또는 `headless_jwks`가 존재해야 합니다.
|
||||
- `headless_login_enabled == true`가 필요합니다.
|
||||
- `metadata.status == "inactive"`인 client는 차단됩니다.
|
||||
|
||||
#### `client_assertion` 규칙
|
||||
- 구현상 `client_assertion`은 현재 필수입니다.
|
||||
- JWT claim의 `iss`와 `sub`는 모두 `client_id`와 같아야 합니다.
|
||||
- `exp`는 현재 시각 이후여야 합니다.
|
||||
- `nbf`, `iat`가 있으면 미래 시각이면 안 됩니다.
|
||||
- `aud`는 다음 둘 중 하나와 일치해야 합니다.
|
||||
- `https://<backend-origin>/api/v1/auth/headless/password/login`
|
||||
- `/api/v1/auth/headless/password/login`
|
||||
- 서명 검증용 public key는 `headless_jwks_uri` 또는 `headless_jwks`에서 읽습니다.
|
||||
|
||||
#### 일반 로그인과의 차이
|
||||
- `POST /api/v1/auth/password/login`
|
||||
- UserFront 기본 비밀번호 로그인용입니다.
|
||||
- `login_challenge`가 없으면 `sessionJwt`를 반환합니다.
|
||||
- `login_challenge`가 있으면 Hydra accept 후 `redirectTo`를 반환합니다.
|
||||
- `POST /api/v1/auth/headless/password/login`
|
||||
- headless login 허용 클라이언트 전용입니다.
|
||||
- `client_assertion` 검증이 추가됩니다.
|
||||
- 항상 `sessionJwt` 없이 `redirectTo`만 반환합니다.
|
||||
|
||||
#### 실패 패턴 요약
|
||||
- `400 bad_request`
|
||||
- 필수 필드 누락
|
||||
- `client_assertion` 누락
|
||||
- `401 invalid_client_assertion`
|
||||
- JWKS 조회 실패
|
||||
- 서명 불일치
|
||||
- `aud`/`iss`/`sub`/`exp` 검증 실패
|
||||
- `403 forbidden`
|
||||
- `client_id` 불일치
|
||||
- `headless_login_enabled` 미설정
|
||||
- inactive client
|
||||
- `401 password_or_email_mismatch`
|
||||
- 사용자 인증 실패
|
||||
|
||||
|
||||
### 전체 연결 구조도
|
||||
|
||||
@@ -416,34 +510,12 @@ cd devfront
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 프로젝트 구조 (Project Structure)
|
||||
|
||||
```
|
||||
baron_sso/
|
||||
├── backend/ # Go Fiber 애플리케이션
|
||||
│ ├── cmd/server/ # 진입점 (Entry point)
|
||||
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
|
||||
│ └── Dockerfile
|
||||
├── userfront/ # Flutter 애플리케이션
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── adminfront/ # React 기반 관리
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
|
||||
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── docker-compose.yaml # 앱 서비스 (Front, Back)
|
||||
├── .env.sample # 환경 설정 템플릿
|
||||
└── README.md # 본 파일
|
||||
```
|
||||
|
||||
## 📝 상태 및 로드맵 (Status & Roadmap)
|
||||
- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료)
|
||||
- [x] **Phase 2**: Backend Audit API 구현 (일부 완료)
|
||||
- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정)
|
||||
- [ ] **Phase 4**: adminfront 기능 추가 (예정)
|
||||
- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정)
|
||||
## 버그 대응 대원칙 (필수)
|
||||
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
|
||||
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
|
||||
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
|
||||
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
|
||||
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
|
||||
|
||||
@@ -27,8 +27,8 @@ type HydraClient struct {
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c *HydraClient) IsTrustedRP() bool {
|
||||
// A Trusted RP must have a public key registered (URI or Inline)
|
||||
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||
// A headless login client must have a public key registered (URI or Inline)
|
||||
// and use private_key_jwt for token endpoint authentication.
|
||||
hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil
|
||||
isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt"
|
||||
@@ -67,7 +67,7 @@ func (c *HydraClient) HeadlessJWKS() interface{} {
|
||||
}
|
||||
|
||||
func (c *HydraClient) IsHeadlessLoginEnabled() bool {
|
||||
if !c.IsTrustedRP() {
|
||||
if !c.SupportsHeadlessLogin() {
|
||||
return false
|
||||
}
|
||||
if c.Metadata == nil {
|
||||
|
||||
@@ -2,8 +2,8 @@ package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
||||
t.Run("metadata-backed headless trusted rp is supported", func(t *testing.T) {
|
||||
func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
|
||||
t.Run("metadata-backed headless login client is supported", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
@@ -17,8 +17,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !client.IsTrustedRP() {
|
||||
t.Fatalf("expected metadata-backed trusted rp")
|
||||
if !client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected metadata-backed headless login client")
|
||||
}
|
||||
if !client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected metadata-backed headless login enabled")
|
||||
@@ -38,15 +38,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !client.IsTrustedRP() {
|
||||
t.Fatalf("expected trusted rp")
|
||||
if !client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login client")
|
||||
}
|
||||
if !client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jwks uri without private_key_jwt is not trusted", func(t *testing.T) {
|
||||
t.Run("jwks uri without private_key_jwt does not support headless login", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "none",
|
||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||
@@ -55,15 +55,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if client.IsTrustedRP() {
|
||||
t.Fatalf("expected untrusted rp")
|
||||
if client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login prerequisites to be missing")
|
||||
}
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled when client is not trusted")
|
||||
t.Fatalf("expected headless login disabled when prerequisites are missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trusted rp without boolean metadata flag is not headless enabled", func(t *testing.T) {
|
||||
t.Run("headless login client without boolean metadata flag is not enabled", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||
@@ -72,8 +72,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !client.IsTrustedRP() {
|
||||
t.Fatalf("expected trusted rp")
|
||||
if !client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login client")
|
||||
}
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled for non-bool metadata")
|
||||
|
||||
@@ -1728,7 +1728,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
|
||||
}
|
||||
raw = body
|
||||
default:
|
||||
return nil, fmt.Errorf("trusted rp public key is not configured")
|
||||
return nil, fmt.Errorf("headless login public key is not configured")
|
||||
}
|
||||
|
||||
var keySet jose.JSONWebKeySet
|
||||
@@ -1736,7 +1736,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
|
||||
return nil, fmt.Errorf("failed to decode jwks: %w", err)
|
||||
}
|
||||
if len(keySet.Keys) == 0 {
|
||||
return nil, fmt.Errorf("trusted rp jwks has no keys")
|
||||
return nil, fmt.Errorf("headless login jwks has no keys")
|
||||
}
|
||||
return &keySet, nil
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
|
||||
assert.Equal(t, "expired_token", got["code"])
|
||||
}
|
||||
|
||||
func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
|
||||
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
@@ -215,8 +215,8 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
|
||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"),
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
|
||||
"loginId": "010-1234-5678",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
@@ -248,7 +248,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
@@ -284,8 +284,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||
|
||||
initBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"),
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
|
||||
"loginId": "010-1234-5678",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
@@ -318,8 +318,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
pollBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/poll"),
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"),
|
||||
"pendingRef": pendingRef,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))
|
||||
|
||||
@@ -284,7 +284,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
|
||||
func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -305,7 +305,7 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
@@ -339,11 +339,11 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
|
||||
clientAssertion := mustHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"trusted-rp",
|
||||
"headless-login-client",
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
@@ -390,7 +390,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: map[string]any{
|
||||
"keys": []map[string]any{},
|
||||
@@ -421,7 +421,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_id": "headless-login-client",
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
@@ -460,7 +460,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: jwks,
|
||||
Metadata: map[string]interface{}{
|
||||
@@ -491,11 +491,11 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
clientAssertion := mustHeadlessClientAssertion(
|
||||
t,
|
||||
invalidKey,
|
||||
"trusted-rp",
|
||||
"headless-login-client",
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
@@ -524,7 +524,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
@@ -549,7 +549,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_id": "headless-login-client",
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
@@ -603,7 +603,7 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_id": "headless-login-client",
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
|
||||
@@ -611,7 +611,7 @@ func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
|
||||
func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
@@ -653,7 +653,7 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Trusted RP App",
|
||||
"name": "Headless Login App",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"https://rp.example.com/callback"},
|
||||
"scopes": []string{"openid", "profile"},
|
||||
@@ -685,14 +685,14 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
|
||||
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"])
|
||||
}
|
||||
|
||||
func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
|
||||
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-trusted",
|
||||
"client_name": "Trusted Before",
|
||||
"client_id": "client-headless-login",
|
||||
"client_name": "Headless Login Before",
|
||||
"redirect_uris": []string{"https://before.example.com/callback"},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
@@ -703,7 +703,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" {
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
err = json.Unmarshal(body, &captured)
|
||||
@@ -741,7 +741,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Trusted After",
|
||||
"name": "Headless Login After",
|
||||
"type": "pkce",
|
||||
"tokenEndpointAuthMethod": "private_key_jwt",
|
||||
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
|
||||
@@ -750,7 +750,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
|
||||
"request_object_signing_alg": "RS256",
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(body))
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
@@ -425,16 +425,16 @@ func TestRotateClientSecret_PersistsForLaterDetailFetch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
||||
func TestUpdateClient_HeadlessLoginSecretPersistsForLaterDetailFetch(t *testing.T) {
|
||||
getCount := 0
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
||||
getCount++
|
||||
if getCount == 1 {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-trusted",
|
||||
"client_name": "Trusted Before",
|
||||
"client_id": "client-headless-login",
|
||||
"client_name": "Headless Login Before",
|
||||
"redirect_uris": []string{"https://before.example.com/callback"},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
@@ -447,14 +447,14 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-trusted",
|
||||
"client_name": "Trusted After",
|
||||
"redirect_uris": []string{"https://trusted.example.com/callback"},
|
||||
"client_id": "client-headless-login",
|
||||
"client_name": "Headless Login After",
|
||||
"redirect_uris": []string{"https://headless.example.com/callback"},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile",
|
||||
"token_endpoint_auth_method": "private_key_jwt",
|
||||
"jwks_uri": "https://trusted.example.com/jwks.json",
|
||||
"jwks_uri": "https://headless.example.com/jwks.json",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
@@ -463,17 +463,17 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
||||
}), nil
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" {
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-trusted",
|
||||
"client_name": "Trusted After",
|
||||
"client_secret": "trusted-secret",
|
||||
"redirect_uris": []string{"https://trusted.example.com/callback"},
|
||||
"client_id": "client-headless-login",
|
||||
"client_name": "Headless Login After",
|
||||
"client_secret": "headless-secret",
|
||||
"redirect_uris": []string{"https://headless.example.com/callback"},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile",
|
||||
"token_endpoint_auth_method": "private_key_jwt",
|
||||
"jwks_uri": "https://trusted.example.com/jwks.json",
|
||||
"jwks_uri": "https://headless.example.com/jwks.json",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
@@ -507,16 +507,16 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
updateBody, _ := json.Marshal(map[string]any{
|
||||
"name": "Trusted After",
|
||||
"redirectUris": []string{"https://trusted.example.com/callback"},
|
||||
"name": "Headless Login After",
|
||||
"redirectUris": []string{"https://headless.example.com/callback"},
|
||||
"tokenEndpointAuthMethod": "private_key_jwt",
|
||||
"jwksUri": "https://trusted.example.com/jwks.json",
|
||||
"jwksUri": "https://headless.example.com/jwks.json",
|
||||
"metadata": map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
"request_object_signing_alg": "RS256",
|
||||
},
|
||||
})
|
||||
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(updateBody))
|
||||
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
updateResp, err := app.Test(updateReq, -1)
|
||||
@@ -527,20 +527,20 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
||||
t.Fatalf("expected update 200, got %d", updateResp.StatusCode)
|
||||
}
|
||||
|
||||
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-trusted")
|
||||
if storedSecret != "trusted-secret" {
|
||||
t.Fatalf("expected postgres secret trusted-secret, got %q", storedSecret)
|
||||
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-headless-login")
|
||||
if storedSecret != "headless-secret" {
|
||||
t.Fatalf("expected postgres secret headless-secret, got %q", storedSecret)
|
||||
}
|
||||
|
||||
redisSecret, err := redisRepo.Get("client_secret:client-trusted")
|
||||
redisSecret, err := redisRepo.Get("client_secret:client-headless-login")
|
||||
if err != nil {
|
||||
t.Fatalf("expected redis secret, got error: %v", err)
|
||||
}
|
||||
if redisSecret != "trusted-secret" {
|
||||
t.Fatalf("expected redis secret trusted-secret, got %q", redisSecret)
|
||||
if redisSecret != "headless-secret" {
|
||||
t.Fatalf("expected redis secret headless-secret, got %q", redisSecret)
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-trusted", nil)
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-headless-login", nil)
|
||||
getResp, err := app.Test(getReq, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("get request failed: %v", err)
|
||||
@@ -557,7 +557,7 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
||||
if err := json.NewDecoder(getResp.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Client.ClientSecret != "trusted-secret" {
|
||||
t.Fatalf("expected detail secret trusted-secret, got %q", payload.Client.ClientSecret)
|
||||
if payload.Client.ClientSecret != "headless-secret" {
|
||||
t.Fatalf("expected detail secret headless-secret, got %q", payload.Client.ClientSecret)
|
||||
}
|
||||
}
|
||||
@@ -972,22 +972,22 @@ function ClientGeneralPage() {
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
className="text-xs font-bold cursor-pointer"
|
||||
htmlFor="trusted-rp-toggle"
|
||||
htmlFor="headless-login-toggle"
|
||||
>
|
||||
{t(
|
||||
"ui.dev.clients.general.security.trusted_rp_enable",
|
||||
"ui.dev.clients.general.security.headless_login_enable",
|
||||
"Headless Login (자체 로그인 UI 사용)",
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.security.trusted_rp_enable_help",
|
||||
"ui.dev.clients.general.security.headless_login_enable_help",
|
||||
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="trusted-rp-toggle"
|
||||
id="headless-login-toggle"
|
||||
checked={headlessLoginEnabled}
|
||||
onCheckedChange={handleHeadlessToggle}
|
||||
/>
|
||||
|
||||
@@ -390,7 +390,7 @@ subtitle = "Define the permission scopes this application can request."
|
||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||
subtitle = "Select application type. Security level determines authentication method."
|
||||
trusted_help = "Operate as Headless Login using private_key_jwt and public key registration. Headless login is only available for this profile."
|
||||
headless_login_help = "Operate as Headless Login using private_key_jwt and public key registration. Headless login is only available for this profile."
|
||||
|
||||
[msg.dev.clients.general.public_key]
|
||||
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
|
||||
@@ -1392,10 +1392,10 @@ delete = "Delete"
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server Side App"
|
||||
pkce = "PKCE"
|
||||
trusted = "Headless Login"
|
||||
headless_login = "Headless Login"
|
||||
title = "Security Settings"
|
||||
trusted_rp_enable = "Headless Login (Custom Login UI)"
|
||||
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
|
||||
headless_login_enable = "Headless Login (Custom Login UI)"
|
||||
headless_login_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
|
||||
|
||||
[ui.dev.clients.general.public_key]
|
||||
auth_method = "Token Endpoint Auth Method"
|
||||
|
||||
@@ -390,7 +390,7 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
||||
trusted_help = "private_key_jwt와 공개키 등록을 사용해 Headless Login으로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
||||
headless_login_help = "private_key_jwt와 공개키 등록을 사용해 Headless Login으로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
||||
|
||||
[msg.dev.clients.general.public_key]
|
||||
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
|
||||
@@ -1393,8 +1393,8 @@ delete = "삭제"
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
title = "보안 설정"
|
||||
trusted_rp_enable = "Headless Login (자체 로그인 UI 사용)"
|
||||
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
|
||||
headless_login_enable = "Headless Login (자체 로그인 UI 사용)"
|
||||
headless_login_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
|
||||
|
||||
|
||||
[ui.dev.clients.general.public_key]
|
||||
|
||||
@@ -390,7 +390,7 @@ subtitle = ""
|
||||
private_help = ""
|
||||
pkce_help = ""
|
||||
subtitle = ""
|
||||
trusted_help = ""
|
||||
headless_login_help = ""
|
||||
|
||||
[msg.dev.clients.general.public_key]
|
||||
auth_method_client_secret_basic_help = ""
|
||||
@@ -1392,8 +1392,8 @@ delete = ""
|
||||
private = ""
|
||||
pkce = ""
|
||||
title = ""
|
||||
trusted_rp_enable = ""
|
||||
trusted_rp_enable_help = ""
|
||||
headless_login_enable = ""
|
||||
headless_login_enable_help = ""
|
||||
|
||||
[ui.dev.clients.general.public_key]
|
||||
auth_method = ""
|
||||
|
||||
@@ -124,18 +124,18 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
});
|
||||
|
||||
test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
|
||||
page,
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-trusted", { name: "Trusted App", type: "pkce" }),
|
||||
makeClient("client-headless-login", { name: "Headless Login App", type: "pkce" }),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-trusted/settings");
|
||||
await page.goto("/clients/client-headless-login/settings");
|
||||
|
||||
await page
|
||||
.getByRole("switch", {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Headless Password Login Backend API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Trusted RP에 한해 `login_challenge + loginId + password`를 받아 OIDC 로그인 흐름을 계속할 수 있는 백엔드 API를 추가합니다.
|
||||
|
||||
**Architecture:** 기존 `AuthHandler.PasswordLogin`의 IDP sign-in, identity resolve, Hydra login accept 흐름을 재사용 가능한 helper로 정리하고, 신규 endpoint에서는 Hydra client의 trusted/headless 상태를 추가 검증합니다. 성공 응답은 `sessionJwt`를 숨기고 `redirectTo`만 반환해 RP 브라우저가 기존 OIDC 흐름을 이어가도록 합니다.
|
||||
|
||||
**Tech Stack:** Go, Fiber, Ory Hydra Admin API, Kratos/Ory provider, testify
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Failing Test 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/handler/auth_handler_login_test.go`
|
||||
|
||||
- [ ] **Step 1: headless trusted RP 성공 케이스 테스트 추가**
|
||||
|
||||
`POST /api/v1/auth/headless/password/login` 호출 시 trusted + headless enabled client면 `redirectTo`만 반환하는 테스트를 추가합니다.
|
||||
|
||||
- [ ] **Step 2: headless 미허용/불일치 케이스 테스트 추가**
|
||||
|
||||
아래 케이스를 각각 추가합니다.
|
||||
- headless flag 없음
|
||||
- trusted RP 조건 미충족
|
||||
- request body `client_id`와 Hydra login request client 불일치
|
||||
- inactive client
|
||||
|
||||
- [ ] **Step 3: 테스트 단독 실행으로 실패 확인**
|
||||
|
||||
Run: `go test ./backend/internal/handler -run 'TestHeadlessPasswordLogin'`
|
||||
|
||||
Expected: 신규 route 또는 handler 부재로 FAIL
|
||||
|
||||
### Task 2: 신규 API 최소 구현
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/cmd/server/main.go`
|
||||
- Modify: `backend/internal/handler/auth_handler.go`
|
||||
|
||||
- [ ] **Step 1: route 추가**
|
||||
|
||||
`/api/v1/auth/headless/password/login` route를 등록합니다.
|
||||
|
||||
- [ ] **Step 2: request body 및 검증 helper 추가**
|
||||
|
||||
`client_id`, `login_challenge`, `loginId`, `password`를 받는 handler를 추가하고, Hydra login request의 client 일치 여부와 trusted/headless/inactive 상태를 검증합니다.
|
||||
|
||||
- [ ] **Step 3: 공통 로그인 처리 helper로 기존 로직 재사용**
|
||||
|
||||
기존 `PasswordLogin`의 sign-in, identity resolve, Hydra accept 흐름을 helper로 분리하거나 최소 중복으로 재사용합니다.
|
||||
|
||||
- [ ] **Step 4: 성공 응답을 headless 정책에 맞게 고정**
|
||||
|
||||
성공 시 `redirectTo`, `status`, `provider`만 응답하고 `sessionJwt`는 반환하지 않도록 합니다.
|
||||
|
||||
### Task 3: 검증 및 회귀 확인
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/handler/auth_handler_login_test.go`
|
||||
|
||||
- [ ] **Step 1: 신규 테스트 재실행**
|
||||
|
||||
Run: `go test ./backend/internal/handler -run 'TestHeadlessPasswordLogin'`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: 기존 password login 관련 회귀 테스트 실행**
|
||||
|
||||
Run: `go test ./backend/internal/handler -run 'TestPasswordLogin'`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: 이슈 업데이트**
|
||||
|
||||
`#480`에 테스트 결과와 남은 후속 범위(전화번호 링크 승인형 분리)를 코멘트로 남깁니다.
|
||||
Reference in New Issue
Block a user