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 } 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) { // JSON Patch format payload := []map[string]any{ { "op": "replace", "path": "/metadata/status", "value": 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/json-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() if resp.StatusCode == http.StatusNoContent { return []domain.HydraConsentSession{}, nil } body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) if resp.StatusCode >= 300 { return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body)) } if len(body) == 0 { return []domain.HydraConsentSession{}, nil } var sessions []domain.HydraConsentSession if err := json.Unmarshal(body, &sessions); err != nil { return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w body=%s", err, string(body)) } 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 } else { params["all"] = "true" } 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"` } type RejectConsentRequestResponse struct { RedirectTo string `json:"redirectTo"` } type RejectLoginRequestResponse 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) RejectConsentRequest(ctx context.Context, challenge string) (*RejectConsentRequestResponse, error) { params := map[string]string{ "consent_challenge": challenge, } endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/consent/reject", params) if err != nil { return nil, err } payload := map[string]any{ "error": "access_denied", "error_description": "The user decided to reject the consent request.", } 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 reject 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: reject 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: reject consent failed status=%d body=%s", resp.StatusCode, string(respBody)) } var hydraResp struct { RedirectTo string `json:"redirect_to"` } if err := json.Unmarshal(respBody, &hydraResp); err != nil { return nil, fmt.Errorf("hydra admin: decode reject consent response failed: %w", err) } return &RejectConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil } func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, error, errorDescription string) (*RejectLoginRequestResponse, error) { params := map[string]string{ "login_challenge": challenge, } endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/login/reject", params) if err != nil { return nil, err } payload := map[string]any{ "error": error, "error_description": errorDescription, } 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 reject 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: reject 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: reject login failed status=%d body=%s", resp.StatusCode, string(respBody)) } var hydraResp struct { RedirectTo string `json:"redirect_to"` } if err := json.Unmarshal(respBody, &hydraResp); err != nil { return nil, fmt.Errorf("hydra admin: decode reject login response failed: %w", err) } return &RejectLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil } func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge string) (*domain.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 domain.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]any{ "grant_scope": grantInfo.RequestedScope, "grant_audience": grantInfo.RequestedAudience, "remember": true, "remember_for": 2592000, } 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]any{ "subject": subject, "remember": true, "remember_for": 2592000, } 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 } type HydraIntrospectionResponse struct { Active bool `json:"active"` Subject string `json:"sub"` ClientID string `json:"client_id"` Scope string `json:"scope"` ExpiresAt int64 `json:"exp"` IssuedAt int64 `json:"iat"` Ext map[string]any `json:"ext"` } func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) { endpoint := fmt.Sprintf("%s/oauth2/introspect", strings.TrimRight(s.AdminURL, "/")) form := url.Values{} form.Set("token", token) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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: introspection failed status=%d body=%s", resp.StatusCode, string(body)) } var res HydraIntrospectionResponse if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { return nil, err } return &res, nil }