From f5c4ffa92ff3ad5f500176fbc02a71ac25a178c5 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 10:47:57 +0900 Subject: [PATCH] =?UTF-8?q?linked=20RP=20=EC=9D=91=EB=8B=B5=EC=97=90=201st?= =?UTF-8?q?-party=20=EC=95=B1=20=EC=9E=90=EB=8F=99=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20init=5Furl=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 100 +++++++++++++++--- .../handler/auth_handler_linked_test.go | 41 +++++-- 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d1195a30..40823eab 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4483,7 +4483,8 @@ type linkedRpSummary struct { ID string `json:"id"` Name string `json:"name"` Logo string `json:"logo,omitempty"` - URL string `json:"url,omitempty"` // Added + URL string `json:"url,omitempty"` + InitURL string `json:"init_url,omitempty"` LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"` Status string `json:"status"` Scopes []string `json:"scopes,omitempty"` @@ -4564,17 +4565,19 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" { scopes = strings.Fields(client.Scope) } + initURL := resolveLinkedRPInitURL(client.ClientID, scopes, client.RedirectURIs) existing := records[clientID] if existing == nil { records[clientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ - ID: clientID, - Name: name, - Logo: extractHydraClientLogo(client.Metadata), - URL: clientURL, - Status: "active", // Hydra 세션이 있으면 활성 - Scopes: scopes, + ID: clientID, + Name: name, + Logo: extractHydraClientLogo(client.Metadata), + URL: clientURL, + InitURL: initURL, + Status: "active", // Hydra 세션이 있으면 활성 + Scopes: scopes, }, lastAuth: lastAuth, } @@ -4590,6 +4593,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { if existing.URL == "" { existing.URL = clientURL } + if existing.InitURL == "" { + existing.InitURL = initURL + } existing.Scopes = mergeScopes(existing.Scopes, scopes) if lastAuth.After(existing.lastAuth) { existing.lastAuth = lastAuth @@ -4644,15 +4650,21 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { client.ClientURI, client.RedirectURIs, ) + initURL := resolveLinkedRPInitURL( + client.ClientID, + dc.GrantedScopes, + client.RedirectURIs, + ) records[dc.ClientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ - ID: dc.ClientID, - Name: name, - Logo: extractHydraClientLogo(client.Metadata), - URL: clientURL, - Status: status, - Scopes: dc.GrantedScopes, + ID: dc.ClientID, + Name: name, + Logo: extractHydraClientLogo(client.Metadata), + URL: clientURL, + InitURL: initURL, + Status: status, + Scopes: dc.GrantedScopes, }, lastAuth: dc.UpdatedAt, } @@ -4726,6 +4738,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { } } record.URL = clientURL + record.InitURL = resolveLinkedRPInitURL( + client.ClientID, + scopes, + client.RedirectURIs, + ) } else { // Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체 if record.Name == "" { @@ -6778,6 +6795,63 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string return "" } +func resolveLinkedRPInitURL(clientID string, scopes []string, redirectURIs []string) string { + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return "" + } + + switch clientID { + case "adminfront": + if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMINFRONT_URL")), "/"); value != "" { + return value + "/login?auto=1" + } + case "devfront": + if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" { + return value + "/login?auto=1&returnTo=%2Fclients" + } + } + + hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/") + if hydraPublicURL == "" { + userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") + if userfrontURL == "" { + userfrontURL = "https://sso.hmac.kr" + } + hydraPublicURL = userfrontURL + "/oidc" + } + + redirectURI := "" + if len(redirectURIs) > 0 { + redirectURI = strings.TrimSpace(redirectURIs[0]) + } + + mergedScopes := make([]string, 0, len(scopes)+1) + seen := map[string]struct{}{} + for _, scope := range append([]string{"openid"}, scopes...) { + scope = strings.TrimSpace(scope) + if scope == "" { + continue + } + if _, ok := seen[scope]; ok { + continue + } + seen[scope] = struct{}{} + mergedScopes = append(mergedScopes, scope) + } + + params := url.Values{} + params.Set("client_id", clientID) + params.Set("response_type", "code") + params.Set("scope", strings.Join(mergedScopes, " ")) + params.Set("state", GenerateSecureAlnumToken(16)) + if redirectURI != "" { + params.Set("redirect_uri", redirectURI) + } + + return fmt.Sprintf("%s/oauth2/auth?%s", hydraPublicURL, params.Encode()) +} + func mergeScopes(current []string, next []string) []string { if len(next) == 0 { return current diff --git a/backend/internal/handler/auth_handler_linked_test.go b/backend/internal/handler/auth_handler_linked_test.go index b9618d77..0c7a9c07 100644 --- a/backend/internal/handler/auth_handler_linked_test.go +++ b/backend/internal/handler/auth_handler_linked_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -45,11 +46,14 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ { "client": map[string]interface{}{ - "client_id": "client-active", - "client_name": "Active App", + "client_id": "devfront", + "client_name": "DevFront", + "redirect_uris": []string{ + "https://active.example.com/callback", + }, }, - "granted_scope": []string{"openid"}, - "handled_at": time.Now().Format(time.RFC3339), + "grant_scope": []string{"openid", "profile"}, + "handled_at": time.Now().Format(time.RFC3339), }, }), nil } @@ -111,6 +115,8 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test") + t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc") + t.Setenv("DEVFRONT_URL", "http://localhost:5174") app := newLinkedRpTestApp(h) @@ -123,10 +129,11 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { var res struct { Items []struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Scopes []string `json:"scopes"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Scopes []string `json:"scopes"` + InitURL string `json:"init_url"` } `json:"items"` } json.NewDecoder(resp.Body).Decode(&res) @@ -138,7 +145,23 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { statusMap[item.ID] = item.Status } - assert.Equal(t, "active", statusMap["client-active"]) + assert.Equal(t, "active", statusMap["devfront"]) assert.Equal(t, "inactive", statusMap["client-consent"]) assert.Equal(t, "inactive", statusMap["client-audit"]) + + var activeInitURL string + for _, item := range res.Items { + if item.ID == "devfront" { + activeInitURL = item.InitURL + break + } + } + + parsedInitURL, err := url.Parse(activeInitURL) + assert.NoError(t, err) + assert.Equal(t, "http", parsedInitURL.Scheme) + assert.Equal(t, "localhost:5174", parsedInitURL.Host) + assert.Equal(t, "/login", parsedInitURL.Path) + assert.Equal(t, "1", parsedInitURL.Query().Get("auto")) + assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo")) }