package service import ( "baron-sso-backend/internal/domain" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" ) var ErrHydraNotFound = errors.New("hydra admin: resource not found") // HydraAdminService는 Hydra Admin API 호출을 래핑합니다. type HydraAdminService struct { AdminURL string PublicURL string HTTPClient *http.Client } type HydraClient struct { ClientID string `json:"client_id"` ClientName string `json:"client_name,omitempty"` ClientSecret string `json:"client_secret,omitempty"` // Added RedirectURIs []string `json:"redirect_uris,omitempty"` GrantTypes []string `json:"grant_types,omitempty"` ResponseTypes []string `json:"response_types,omitempty"` Scope string `json:"scope,omitempty"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } type HydraConsentRequest struct { Challenge string `json:"challenge"` RequestedScope []string `json:"requested_scope"` RequestedAudience []string `json:"requested_access_token_audience"` Skip bool `json:"skip"` Subject string `json:"subject"` Client HydraClient `json:"client"` } type HydraLoginRequest struct { Challenge string `json:"challenge"` Subject string `json:"subject"` Skip bool `json:"skip"` Client HydraClient `json:"client"` } type HydraConsentSession struct { ConsentRequestID string `json:"consent_request_id,omitempty"` Subject string `json:"subject,omitempty"` GrantedScope []string `json:"grant_scope,omitempty"` GrantedAudience []string `json:"grant_access_token_audience,omitempty"` Remember bool `json:"remember"` RememberFor int `json:"remember_for,omitempty"` AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` RequestedAt *time.Time `json:"requested_at,omitempty"` HandledAt *time.Time `json:"handled_at,omitempty"` Client HydraClient `json:"client,omitempty"` ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"` } func NewHydraAdminService() *HydraAdminService { return &HydraAdminService{ AdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"), PublicURL: getenv("HYDRA_PUBLIC_URL", "http://hydra:4444"), } } func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error) { endpoint, err := s.buildURL("/clients", map[string]int{ "limit": limit, "offset": offset, }) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } resp, err := s.httpClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, ErrHydraNotFound } if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("hydra admin: list clients failed status=%d body=%s", resp.StatusCode, string(body)) } var clients []domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil { return nil, fmt.Errorf("hydra admin: decode clients failed: %w", err) } return clients, nil } func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*domain.HydraClient, error) { endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } resp, err := s.httpClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, ErrHydraNotFound } if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("hydra admin: get client failed status=%d body=%s", resp.StatusCode, string(body)) } var client domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&client); err != nil { return nil, fmt.Errorf("hydra admin: decode client failed: %w", err) } return &client, nil } func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) { payload := map[string]interface{}{ "metadata": map[string]interface{}{ "status": status, }, } body, _ := json.Marshal(payload) endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID)) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/merge-patch+json") resp, err := s.httpClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, ErrHydraNotFound } if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("hydra admin: patch client failed status=%d body=%s", resp.StatusCode, string(respBody)) } var updated domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { return nil, fmt.Errorf("hydra admin: decode patched client failed: %w", err) } return &updated, nil } func (s *HydraAdminService) CreateClient(ctx context.Context, client domain.HydraClient) (*domain.HydraClient, error) { body, _ := json.Marshal(client) endpoint := fmt.Sprintf("%s/clients", strings.TrimRight(s.AdminURL, "/")) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("hydra admin: create client failed status=%d body=%s", resp.StatusCode, string(respBody)) } var created domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { return nil, fmt.Errorf("hydra admin: decode created client failed: %w", err) } return &created, nil } func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, client domain.HydraClient) (*domain.HydraClient, error) { client.ClientID = clientID body, _ := json.Marshal(client) endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID)) req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, ErrHydraNotFound } if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("hydra admin: update client failed status=%d body=%s", resp.StatusCode, string(respBody)) } var updated domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { return nil, fmt.Errorf("hydra admin: decode updated client failed: %w", err) } return &updated, nil } func (s *HydraAdminService) DeleteClient(ctx context.Context, clientID string) error { endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID)) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } resp, err := s.httpClient().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return ErrHydraNotFound } if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return fmt.Errorf("hydra admin: delete client failed status=%d body=%s", resp.StatusCode, string(body)) } return nil } func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, clientID string) ([]domain.HydraConsentSession, error) { params := map[string]string{ "subject": subject, } if clientID != "" { params["client"] = clientID } endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } resp, err := s.httpClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 300 { return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body)) } var sessions []domain.HydraConsentSession if err := json.Unmarshal(body, &sessions); err != nil { return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err) } return sessions, nil } func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, clientID string) error { params := map[string]string{ "subject": subject, } if clientID != "" { params["client"] = clientID } endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } resp, err := s.httpClient().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return fmt.Errorf("hydra admin: revoke consent failed status=%d body=%s", resp.StatusCode, string(body)) } return nil } func (s *HydraAdminService) httpClient() *http.Client { if s.HTTPClient != nil { return s.HTTPClient } return &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 5 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 5 * time.Second, }, } } func (s *HydraAdminService) buildURL(path string, ints map[string]int) (string, error) { base := strings.TrimRight(s.AdminURL, "/") u, err := url.Parse(base + path) if err != nil { return "", err } q := u.Query() for key, value := range ints { if value > 0 { q.Set(key, strconv.Itoa(value)) } } u.RawQuery = q.Encode() return u.String(), nil } func (s *HydraAdminService) buildURLWithParams(path string, params map[string]string) (string, error) { base := strings.TrimRight(s.AdminURL, "/") u, err := url.Parse(base + path) if err != nil { return "", err } q := u.Query() for key, value := range params { if value != "" { q.Set(key, value) } } u.RawQuery = q.Encode() return u.String(), nil } type AcceptLoginRequestResponse struct { RedirectTo string `json:"redirectTo"` } type AcceptConsentRequestResponse struct { RedirectTo string `json:"redirectTo"` } func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge string) (*domain.HydraConsentRequest, error) { params := map[string]string{ "consent_challenge": challenge, } endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/consent", params) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("hydra admin: create request for get consent failed: %w", err) } resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: get consent request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("hydra admin: get consent failed status=%d body=%s", resp.StatusCode, string(body)) } var consentReq domain.HydraConsentRequest if err := json.Unmarshal(body, &consentReq); err != nil { return nil, fmt.Errorf("hydra admin: decode get consent response failed: %w", err) } return &consentReq, nil } func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge string) (*HydraLoginRequest, error) { params := map[string]string{ "login_challenge": challenge, } endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/login", params) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("hydra admin: create request for get login failed: %w", err) } resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: get login request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("hydra admin: get login failed status=%d body=%s", resp.StatusCode, string(body)) } var loginReq HydraLoginRequest if err := json.Unmarshal(body, &loginReq); err != nil { return nil, fmt.Errorf("hydra admin: decode get login response failed: %w", err) } return &loginReq, nil } func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *domain.HydraConsentRequest, sessionClaims map[string]any) (*AcceptConsentRequestResponse, error) { params := map[string]string{ "consent_challenge": challenge, } endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/consent/accept", params) if err != nil { return nil, err } payload := map[string]interface{}{ "grant_scope": grantInfo.RequestedScope, "grant_audience": grantInfo.RequestedAudience, "remember": true, "remember_for": 3600, } if len(sessionClaims) > 0 { payload["session"] = map[string]any{ "id_token": sessionClaims, "access_token": sessionClaims, } } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("hydra admin: create request for accept consent failed: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: accept consent request failed: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("hydra admin: accept consent failed status=%d body=%s", resp.StatusCode, string(respBody)) } // Hydra 응답(redirect_to)을 읽어서 우리 응답(redirectTo)으로 변환 var hydraResp struct { RedirectTo string `json:"redirect_to"` } if err := json.Unmarshal(respBody, &hydraResp); err != nil { return nil, fmt.Errorf("hydra admin: decode accept consent response failed: %w", err) } return &AcceptConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil } func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge string, subject string) (*AcceptLoginRequestResponse, error) { params := map[string]string{ "login_challenge": challenge, } endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/login/accept", params) if err != nil { return nil, err } payload := map[string]interface{}{ "subject": subject, "remember": true, "remember_for": 3600, } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("hydra admin: create request for accept login failed: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: accept login request failed: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("hydra admin: accept login failed status=%d body=%s", resp.StatusCode, string(respBody)) } // Hydra 응답(redirect_to)을 읽어서 우리 응답(redirectTo)으로 변환 var hydraResp struct { RedirectTo string `json:"redirect_to"` } if err := json.Unmarshal(respBody, &hydraResp); err != nil { return nil, fmt.Errorf("hydra admin: decode accept login response failed: %w", err) } return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil }