package service import ( "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"` 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 HydraConsentSession struct { Subject string `json:"subject"` GrantedScope []string `json:"granted_scope"` GrantedAudience []string `json:"granted_audience,omitempty"` Remember bool `json:"remember"` AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` RequestedAt *time.Time `json:"requested_at,omitempty"` Client HydraClient `json:"client"` } 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) ([]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 []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) (*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 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) (*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 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 HydraClient) (*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 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 HydraClient) (*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 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) ([]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() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body)) } var sessions []HydraConsentSession if err := json.NewDecoder(resp.Body).Decode(&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 }