1
0
forked from baron/baron-sso

headless login으로 리펙토링

This commit is contained in:
Lectom C Han
2026-04-01 10:50:31 +09:00
parent d9b0ec410c
commit 94362bf8eb
15 changed files with 276 additions and 127 deletions

View File

@@ -62,7 +62,7 @@ up-front-dev: up-infra up-ory up-backend
@echo "Dev stack is up (infra + ory + backend)." @echo "Dev stack is up (infra + ory + backend)."
# --- 종료 (Down) --- # --- 종료 (Down) ---
down-all: down:
@echo "Stopping ALL stacks (infra + ory + app)..." @echo "Stopping ALL stacks (infra + ory + app)..."
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down

140
README.md
View File

@@ -2,13 +2,27 @@
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다. **Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
## 버그 대응 대원칙 (필수) ## 📂 프로젝트 구조 (Project Structure)
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
```
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 합니다. * Ory Stack으로 모든 구성요소를 self-hosting 합니다.
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다. * Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다. * Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
@@ -20,6 +34,7 @@
* AdminFront: 사용자 관리 등 Admin 기능 * AdminFront: 사용자 관리 등 Admin 기능
* DevFront: RP 관리 등 개발자 기능 * DevFront: RP 관리 등 개발자 기능
## 🏗 아키텍처 (Architecture) ## 🏗 아키텍처 (Architecture)
### 0. Ory Stack ### 0. Ory Stack
@@ -88,6 +103,85 @@ flowchart
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요 2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환 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 install
npm run dev npm run dev
``` ```
--- ---
## 📂 프로젝트 구조 (Project Structure)
``` ## 버그 대응 대원칙 (필수)
baron_sso/ - 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
├── backend/ # Go Fiber 애플리케이션 - 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
│ ├── cmd/server/ # 진입점 (Entry point) - 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository) - “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
│ └── Dockerfile - 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
├── 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**: 대시보드 및 통합 런처 구현 (예정)

View File

@@ -27,8 +27,8 @@ type HydraClient struct {
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"`
} }
func (c *HydraClient) IsTrustedRP() bool { func (c *HydraClient) SupportsHeadlessLogin() bool {
// A Trusted RP must have a public key registered (URI or Inline) // A headless login client must have a public key registered (URI or Inline)
// and use private_key_jwt for token endpoint authentication. // and use private_key_jwt for token endpoint authentication.
hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil
isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt" isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt"
@@ -67,7 +67,7 @@ func (c *HydraClient) HeadlessJWKS() interface{} {
} }
func (c *HydraClient) IsHeadlessLoginEnabled() bool { func (c *HydraClient) IsHeadlessLoginEnabled() bool {
if !c.IsTrustedRP() { if !c.SupportsHeadlessLogin() {
return false return false
} }
if c.Metadata == nil { if c.Metadata == nil {

View File

@@ -2,8 +2,8 @@ package domain
import "testing" import "testing"
func TestHydraClient_TrustedRPFlags(t *testing.T) { func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
t.Run("metadata-backed headless trusted rp is supported", func(t *testing.T) { t.Run("metadata-backed headless login client is supported", func(t *testing.T) {
client := HydraClient{ client := HydraClient{
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]any{ Metadata: map[string]any{
@@ -17,8 +17,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
}, },
} }
if !client.IsTrustedRP() { if !client.SupportsHeadlessLogin() {
t.Fatalf("expected metadata-backed trusted rp") t.Fatalf("expected metadata-backed headless login client")
} }
if !client.IsHeadlessLoginEnabled() { if !client.IsHeadlessLoginEnabled() {
t.Fatalf("expected metadata-backed headless login enabled") t.Fatalf("expected metadata-backed headless login enabled")
@@ -38,15 +38,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
}, },
} }
if !client.IsTrustedRP() { if !client.SupportsHeadlessLogin() {
t.Fatalf("expected trusted rp") t.Fatalf("expected headless login client")
} }
if !client.IsHeadlessLoginEnabled() { if !client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login enabled") 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{ client := HydraClient{
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
JWKSUri: "https://rp.example.com/.well-known/jwks.json", JWKSUri: "https://rp.example.com/.well-known/jwks.json",
@@ -55,15 +55,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
}, },
} }
if client.IsTrustedRP() { if client.SupportsHeadlessLogin() {
t.Fatalf("expected untrusted rp") t.Fatalf("expected headless login prerequisites to be missing")
} }
if client.IsHeadlessLoginEnabled() { 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{ client := HydraClient{
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "private_key_jwt",
JWKSUri: "https://rp.example.com/.well-known/jwks.json", JWKSUri: "https://rp.example.com/.well-known/jwks.json",
@@ -72,8 +72,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
}, },
} }
if !client.IsTrustedRP() { if !client.SupportsHeadlessLogin() {
t.Fatalf("expected trusted rp") t.Fatalf("expected headless login client")
} }
if client.IsHeadlessLoginEnabled() { if client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login disabled for non-bool metadata") t.Fatalf("expected headless login disabled for non-bool metadata")

View File

@@ -1728,7 +1728,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
} }
raw = body raw = body
default: 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 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) return nil, fmt.Errorf("failed to decode jwks: %w", err)
} }
if len(keySet.Keys) == 0 { 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 return &keySet, nil
} }

View File

@@ -172,7 +172,7 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
assert.Equal(t, "expired_token", got["code"]) 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)} redis := &mockRedisRepo{data: make(map[string]string)}
privateKey, jwks := mustHeadlessRSAJWK(t) privateKey, jwks := mustHeadlessRSAJWK(t)
@@ -186,7 +186,7 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
"status": "active", "status": "active",
@@ -215,8 +215,8 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
t.Setenv("USERFRONT_URL", "http://userfront.test") t.Setenv("USERFRONT_URL", "http://userfront.test")
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"), "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
"loginId": "010-1234-5678", "loginId": "010-1234-5678",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
}) })
@@ -248,7 +248,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
"status": "active", "status": "active",
@@ -284,8 +284,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
t.Setenv("USERFRONT_URL", "http://userfront.test") t.Setenv("USERFRONT_URL", "http://userfront.test")
initBody, _ := json.Marshal(map[string]string{ initBody, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"), "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
"loginId": "010-1234-5678", "loginId": "010-1234-5678",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
}) })
@@ -318,8 +318,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
pollBody, _ := json.Marshal(map[string]string{ pollBody, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/poll"), "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"),
"pendingRef": pendingRef, "pendingRef": pendingRef,
}) })
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody)) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))

View File

@@ -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 := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt"}, SessionToken: &domain.Token{JWT: "valid-jwt"},
@@ -305,7 +305,7 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
"status": "active", "status": "active",
@@ -339,11 +339,11 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
clientAssertion := mustHeadlessClientAssertion( clientAssertion := mustHeadlessClientAssertion(
t, t,
privateKey, privateKey,
"trusted-rp", "headless-login-client",
"http://example.com/api/v1/auth/headless/password/login", "http://example.com/api/v1/auth/headless/password/login",
) )
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": clientAssertion, "client_assertion": clientAssertion,
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
@@ -390,7 +390,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "private_key_jwt",
JWKS: map[string]any{ JWKS: map[string]any{
"keys": []map[string]any{}, "keys": []map[string]any{},
@@ -421,7 +421,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
app := newHeadlessPasswordLoginTestApp(h) app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
@@ -460,7 +460,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "private_key_jwt",
JWKS: jwks, JWKS: jwks,
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
@@ -491,11 +491,11 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
clientAssertion := mustHeadlessClientAssertion( clientAssertion := mustHeadlessClientAssertion(
t, t,
invalidKey, invalidKey,
"trusted-rp", "headless-login-client",
"http://example.com/api/v1/auth/headless/password/login", "http://example.com/api/v1/auth/headless/password/login",
) )
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": clientAssertion, "client_assertion": clientAssertion,
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
@@ -524,7 +524,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
"status": "active", "status": "active",
@@ -549,7 +549,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
app := newHeadlessPasswordLoginTestApp(h) app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
@@ -603,7 +603,7 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
app := newHeadlessPasswordLoginTestApp(h) app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",

View File

@@ -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 var captured domain.HydraClient
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { 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) app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"name": "Trusted RP App", "name": "Headless Login App",
"type": "pkce", "type": "pkce",
"redirectUris": []string{"https://rp.example.com/callback"}, "redirectUris": []string{"https://rp.example.com/callback"},
"scopes": []string{"openid", "profile"}, "scopes": []string{"openid", "profile"},
@@ -685,14 +685,14 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"]) 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 var captured domain.HydraClient
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { 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{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted", "client_id": "client-headless-login",
"client_name": "Trusted Before", "client_name": "Headless Login Before",
"redirect_uris": []string{"https://before.example.com/callback"}, "redirect_uris": []string{"https://before.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"}, "grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"}, "response_types": []string{"code"},
@@ -703,7 +703,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
}, },
}), nil }), 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) body, err := io.ReadAll(r.Body)
assert.NoError(t, err) assert.NoError(t, err)
err = json.Unmarshal(body, &captured) err = json.Unmarshal(body, &captured)
@@ -741,7 +741,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
app.Put("/api/v1/dev/clients/:id", h.UpdateClient) app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"name": "Trusted After", "name": "Headless Login After",
"type": "pkce", "type": "pkce",
"tokenEndpointAuthMethod": "private_key_jwt", "tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://rp.example.com/.well-known/jwks.json", "jwksUri": "https://rp.example.com/.well-known/jwks.json",
@@ -750,7 +750,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
"request_object_signing_alg": "RS256", "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") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)

View File

@@ -425,16 +425,16 @@ func TestRotateClientSecret_PersistsForLaterDetailFetch(t *testing.T) {
} }
} }
func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) { func TestUpdateClient_HeadlessLoginSecretPersistsForLaterDetailFetch(t *testing.T) {
getCount := 0 getCount := 0
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { 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++ getCount++
if getCount == 1 { if getCount == 1 {
return httpJSONAny(r, http.StatusOK, map[string]any{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted", "client_id": "client-headless-login",
"client_name": "Trusted Before", "client_name": "Headless Login Before",
"redirect_uris": []string{"https://before.example.com/callback"}, "redirect_uris": []string{"https://before.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"}, "grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"}, "response_types": []string{"code"},
@@ -447,14 +447,14 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
} }
return httpJSONAny(r, http.StatusOK, map[string]any{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted", "client_id": "client-headless-login",
"client_name": "Trusted After", "client_name": "Headless Login After",
"redirect_uris": []string{"https://trusted.example.com/callback"}, "redirect_uris": []string{"https://headless.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"}, "grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"}, "response_types": []string{"code"},
"scope": "openid profile", "scope": "openid profile",
"token_endpoint_auth_method": "private_key_jwt", "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{ "metadata": map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
@@ -463,17 +463,17 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
}), nil }), 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{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted", "client_id": "client-headless-login",
"client_name": "Trusted After", "client_name": "Headless Login After",
"client_secret": "trusted-secret", "client_secret": "headless-secret",
"redirect_uris": []string{"https://trusted.example.com/callback"}, "redirect_uris": []string{"https://headless.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"}, "grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"}, "response_types": []string{"code"},
"scope": "openid profile", "scope": "openid profile",
"token_endpoint_auth_method": "private_key_jwt", "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{ "metadata": map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
@@ -507,16 +507,16 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
app.Get("/api/v1/dev/clients/:id", h.GetClient) app.Get("/api/v1/dev/clients/:id", h.GetClient)
updateBody, _ := json.Marshal(map[string]any{ updateBody, _ := json.Marshal(map[string]any{
"name": "Trusted After", "name": "Headless Login After",
"redirectUris": []string{"https://trusted.example.com/callback"}, "redirectUris": []string{"https://headless.example.com/callback"},
"tokenEndpointAuthMethod": "private_key_jwt", "tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://trusted.example.com/jwks.json", "jwksUri": "https://headless.example.com/jwks.json",
"metadata": map[string]any{ "metadata": map[string]any{
"headless_login_enabled": true, "headless_login_enabled": true,
"request_object_signing_alg": "RS256", "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") updateReq.Header.Set("Content-Type", "application/json")
updateResp, err := app.Test(updateReq, -1) 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) t.Fatalf("expected update 200, got %d", updateResp.StatusCode)
} }
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-trusted") storedSecret, _ := secretRepo.GetByID(context.Background(), "client-headless-login")
if storedSecret != "trusted-secret" { if storedSecret != "headless-secret" {
t.Fatalf("expected postgres secret trusted-secret, got %q", storedSecret) 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 { if err != nil {
t.Fatalf("expected redis secret, got error: %v", err) t.Fatalf("expected redis secret, got error: %v", err)
} }
if redisSecret != "trusted-secret" { if redisSecret != "headless-secret" {
t.Fatalf("expected redis secret trusted-secret, got %q", redisSecret) 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) getResp, err := app.Test(getReq, -1)
if err != nil { if err != nil {
t.Fatalf("get request failed: %v", err) 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 { if err := json.NewDecoder(getResp.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err) t.Fatalf("decode response: %v", err)
} }
if payload.Client.ClientSecret != "trusted-secret" { if payload.Client.ClientSecret != "headless-secret" {
t.Fatalf("expected detail secret trusted-secret, got %q", payload.Client.ClientSecret) t.Fatalf("expected detail secret headless-secret, got %q", payload.Client.ClientSecret)
} }
} }

View File

@@ -972,22 +972,22 @@ function ClientGeneralPage() {
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label <Label
className="text-xs font-bold cursor-pointer" className="text-xs font-bold cursor-pointer"
htmlFor="trusted-rp-toggle" htmlFor="headless-login-toggle"
> >
{t( {t(
"ui.dev.clients.general.security.trusted_rp_enable", "ui.dev.clients.general.security.headless_login_enable",
"Headless Login (자체 로그인 UI 사용)", "Headless Login (자체 로그인 UI 사용)",
)} )}
</Label> </Label>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{t( {t(
"ui.dev.clients.general.security.trusted_rp_enable_help", "ui.dev.clients.general.security.headless_login_enable_help",
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.", "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
)} )}
</p> </p>
</div> </div>
<Switch <Switch
id="trusted-rp-toggle" id="headless-login-toggle"
checked={headlessLoginEnabled} checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle} onCheckedChange={handleHeadlessToggle}
/> />

View File

@@ -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." 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." 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." 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] [msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications." auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
@@ -1392,10 +1392,10 @@ delete = "Delete"
[ui.dev.clients.general.security] [ui.dev.clients.general.security]
private = "Server Side App" private = "Server Side App"
pkce = "PKCE" pkce = "PKCE"
trusted = "Headless Login" headless_login = "Headless Login"
title = "Security Settings" title = "Security Settings"
trusted_rp_enable = "Headless Login (Custom Login UI)" headless_login_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_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] [ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method" auth_method = "Token Endpoint Auth Method"

View File

@@ -390,7 +390,7 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다." private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다." 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] [msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다." auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
@@ -1393,8 +1393,8 @@ delete = "삭제"
private = "Server side App" private = "Server side App"
pkce = "PKCE" pkce = "PKCE"
title = "보안 설정" title = "보안 설정"
trusted_rp_enable = "Headless Login (자체 로그인 UI 사용)" headless_login_enable = "Headless Login (자체 로그인 UI 사용)"
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다." headless_login_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
[ui.dev.clients.general.public_key] [ui.dev.clients.general.public_key]

View File

@@ -390,7 +390,7 @@ subtitle = ""
private_help = "" private_help = ""
pkce_help = "" pkce_help = ""
subtitle = "" subtitle = ""
trusted_help = "" headless_login_help = ""
[msg.dev.clients.general.public_key] [msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "" auth_method_client_secret_basic_help = ""
@@ -1392,8 +1392,8 @@ delete = ""
private = "" private = ""
pkce = "" pkce = ""
title = "" title = ""
trusted_rp_enable = "" headless_login_enable = ""
trusted_rp_enable_help = "" headless_login_enable_help = ""
[ui.dev.clients.general.public_key] [ui.dev.clients.general.public_key]
auth_method = "" auth_method = ""

View File

@@ -124,18 +124,18 @@ test.describe("DevFront clients lifecycle", () => {
}); });
test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({ test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
page, page,
}) => { }) => {
const state = { const state = {
clients: [ clients: [
makeClient("client-trusted", { name: "Trusted App", type: "pkce" }), makeClient("client-headless-login", { name: "Headless Login App", type: "pkce" }),
], ],
consents: [] as Consent[], consents: [] as Consent[],
auditLogsByCursor: undefined, auditLogsByCursor: undefined,
}; };
await installDevApiMock(page, state); await installDevApiMock(page, state);
await page.goto("/clients/client-trusted/settings"); await page.goto("/clients/client-headless-login/settings");
await page await page
.getByRole("switch", { .getByRole("switch", {

View File

@@ -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`에 테스트 결과와 남은 후속 범위(전화번호 링크 승인형 분리)를 코멘트로 남깁니다.