package service import ( "baron-sso-backend/internal/domain" "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "os" "strings" "time" "golang.org/x/crypto/bcrypt" ) type KratosIdentity struct { ID string `json:"id"` SchemaID string `json:"schema_id,omitempty"` Traits map[string]any `json:"traits"` State string `json:"state,omitempty"` MetadataAdmin any `json:"metadata_admin,omitempty"` MetadataPublic any `json:"metadata_public,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type KratosSessionDevice struct { UserAgent string `json:"user_agent,omitempty"` IPAddress string `json:"ip_address,omitempty"` } type KratosSession struct { ID string `json:"id"` Active bool `json:"active"` AuthenticatedAt time.Time `json:"authenticated_at"` ExpiresAt time.Time `json:"expires_at"` IssuedAt time.Time `json:"issued_at"` Identity *KratosIdentity `json:"identity,omitempty"` Devices []KratosSessionDevice `json:"devices,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]any, state string) (*KratosIdentity, error) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error DeleteIdentity(ctx context.Context, identityID string) error CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) DeleteSession(ctx context.Context, sessionID 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" // 1. Try credentials_identifier (Email/LoginID/Phone) id, err := s.searchIdentities(ctx, endpoint, "credentials_identifier", identifier) if err == nil && id != "" { // VERIFY: Kratos sometimes ignores unknown query params and returns the first identity. if s.verifyIdentityMatch(ctx, id, identifier) { return id, nil } } identity, err := s.GetIdentity(ctx, identifier) if err == nil && identity != nil { return identity.ID, nil } return "", nil } func (s *kratosAdminService) verifyIdentityMatch(ctx context.Context, id, identifier string) bool { identity, err := s.GetIdentity(ctx, id) if err != nil || identity == nil { return false } // Exact ID match if strings.EqualFold(identity.ID, identifier) { return true } // Check traits (Email, CustomLoginIDs) if email, ok := identity.Traits["email"].(string); ok && strings.EqualFold(email, identifier) { return true } if phone, ok := identity.Traits["phone_number"].(string); ok && strings.EqualFold(phone, identifier) { return true } if lids, ok := identity.Traits["custom_login_ids"].([]any); ok { for _, lid := range lids { if s, ok := lid.(string); ok && strings.EqualFold(s, identifier) { return true } } } else if lids, ok := identity.Traits["custom_login_ids"].([]string); ok { for _, lid := range lids { if strings.EqualFold(lid, identifier) { return true } } } return false } func (s *kratosAdminService) searchIdentities(ctx context.Context, endpoint, key, value string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", err } query := req.URL.Query() query.Set(key, value) 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 by %s failed status=%d body=%s", key, 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]any, state string) (*KratosIdentity, error) { payload := map[string]any{ "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 { identity, err := s.GetIdentity(ctx, identityID) if err != nil { return err } if identity == nil { return fmt.Errorf("kratos admin identity not found: %s", identityID) } hashedPassword, err := hashPasswordForKratosAdmin(newPassword) if err != nil { return err } payload := map[string]any{ "schema_id": identity.SchemaID, "traits": identity.Traits, "state": identity.State, "credentials": map[string]any{ "password": map[string]any{ "config": map[string]string{ "hashed_password": hashedPassword, }, }, }, } if payload["schema_id"] == "" { payload["schema_id"] = "default" } if payload["state"] == "" { payload["state"] = "active" } if identity.MetadataAdmin != nil { payload["metadata_admin"] = identity.MetadataAdmin } if identity.MetadataPublic != nil { payload["metadata_public"] = identity.MetadataPublic } 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 err } req.Header.Set("Content-Type", "application/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) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { if user == nil { return "", fmt.Errorf("kratos admin: user payload is nil") } if strings.TrimSpace(user.ID) != "" { return "", fmt.Errorf("kratos admin: requested identity id import is disabled; use backup/restore") } traits := map[string]any{ "email": user.Email, "name": user.Name, } if user.PhoneNumber != "" { traits["phone_number"] = user.PhoneNumber } for k, v := range user.Attributes { if k == "id" || k == "email" { continue } traits[k] = v } payload := map[string]any{ "schema_id": "default", "traits": traits, "credentials": map[string]any{ "password": map[string]any{ "config": map[string]string{ "password": password, }, }, }, "state": "active", } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/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 create identity failed status=%d body=%s", resp.StatusCode, string(respBody)) } var created struct { ID string `json:"id"` } if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { return "", err } return created.ID, nil } func hashPasswordForKratosAdmin(password string) (string, error) { hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(hashed), 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 } func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { url := fmt.Sprintf("%s/admin/identities/%s/sessions", s.AdminURL, identityID) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } client := s.HTTPClient if client == nil { client = http.DefaultClient } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return []KratosSession{}, nil } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) } var sessions []KratosSession if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { return nil, err } return sessions, nil } func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } client := s.HTTPClient if client == nil { client = http.DefaultClient } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, nil } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) } var session KratosSession if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { return nil, err } return &session, nil } func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID) req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) if err != nil { return err } client := s.HTTPClient if client == nil { client = http.DefaultClient } resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status: %d", resp.StatusCode) } return nil }