package service import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "os" "strings" "time" ) type KratosIdentity struct { ID string `json:"id"` Traits map[string]interface{} `json:"traits"` State string `json:"state,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"` } type KratosAdminService interface { ListIdentities(ctx context.Context) ([]KratosIdentity, error) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error DeleteIdentity(ctx context.Context, identityID string) error } type kratosAdminService struct { AdminURL string HTTPClient *http.Client } func NewKratosAdminService() KratosAdminService { return &kratosAdminService{ AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"), } } func (s *kratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) { endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" 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("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body)) } var identities []KratosIdentity if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil { return nil, err } return identities, nil } func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { identifier = strings.TrimSpace(identifier) if identifier == "" { return "", nil } endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", err } query := req.URL.Query() query.Set("credentials_identifier", identifier) req.URL.RawQuery = query.Encode() resp, err := s.httpClient().Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return "", nil } if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body)) } var identities []struct { ID string `json:"id"` } if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil { return "", err } if len(identities) == 0 { return "", nil } return identities[0].ID, nil } func (s *kratosAdminService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) { endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) 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, nil } if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("kratos admin get identity failed status=%d body=%s", resp.StatusCode, string(body)) } var identity KratosIdentity if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { return nil, err } return &identity, nil } func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) { payload := map[string]interface{}{ "schema_id": "default", "traits": traits, } if strings.TrimSpace(state) != "" { payload["state"] = strings.TrimSpace(state) } body, _ := json.Marshal(payload) endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) 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 >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("kratos admin update identity failed status=%d body=%s", resp.StatusCode, string(respBody)) } var updated KratosIdentity if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { return nil, err } return &updated, nil } func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { patchOps := []map[string]interface{}{ { "op": "add", "path": "/credentials/password/config/password", "value": newPassword, }, } body, _ := json.Marshal(patchOps) endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json-patch+json") resp, err := s.httpClient().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return fmt.Errorf("kratos admin update password failed status=%d body=%s", resp.StatusCode, string(respBody)) } return nil } func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error { endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) 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 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return fmt.Errorf("kratos admin delete identity failed status=%d body=%s", resp.StatusCode, string(respBody)) } return nil } func (s *kratosAdminService) 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 getenvKratos(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }