package service import ( "baron-sso-backend/internal/domain" "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net" "net/http" "net/url" "os" "strings" "time" ) // OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다. // 지금은 스키마 메타데이터만 반환하며, 나머지 동작은 후속 작업에서 구현합니다. type OryProvider struct { KratosAdminURL string KratosPublicURL string HydraAdminURL string HTTPClient *http.Client } func NewOryProvider() *OryProvider { return &OryProvider{ KratosAdminURL: getenv("KRATOS_ADMIN_URL", "http://kratos:4434"), KratosPublicURL: getenv("KRATOS_PUBLIC_URL", "http://kratos:4433"), HydraAdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"), } } func (o *OryProvider) Name() string { return "Ory (Kratos/Hydra)" } // GetMetadata는 BrokerUser가 요구하는 필드를 Kratos traits에 매핑 가능하다는 가정으로 반환합니다. func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) { return &domain.IDPMetadata{ SupportedFields: []string{ "id", "email", "name", "phone_number", "grade", "department", "affiliationType", "companyCode", }, }, nil } // CreateUser는 Kratos Admin API를 통해 identity를 생성합니다. func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { if user == nil { return "", fmt.Errorf("ory provider: user payload is nil") } if user.Email == "" || password == "" { return "", fmt.Errorf("ory provider: email and password are required") } // 중복 확인 existingID, err := o.findIdentityID(user.Email) if err != nil { return "", fmt.Errorf("ory provider: search identity failed: %w", err) } if existingID != "" { return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email) } if user.PhoneNumber != "" { existingPhoneID, err := o.findIdentityID(user.PhoneNumber) if err != nil { return "", fmt.Errorf("ory provider: search identity failed: %w", err) } if existingPhoneID != "" { return "", fmt.Errorf("ory provider: identity already exists for phone=%s", user.PhoneNumber) } } traits := map[string]interface{}{ "email": user.Email, "name": user.Name, } if user.PhoneNumber != "" { traits["phone_number"] = user.PhoneNumber } for k, v := range user.Attributes { traits[k] = v } payload := map[string]interface{}{ "schema_id": "default", "traits": traits, "credentials": map[string]interface{}{ "password": map[string]interface{}{ "config": map[string]string{ "password": password, }, }, }, } verifiable := []map[string]interface{}{ { "value": user.Email, "verified": true, "via": "email", }, } if user.PhoneNumber != "" { verifiable = append(verifiable, map[string]interface{}{ "value": user.PhoneNumber, "verified": true, "via": "sms", }) } payload["verifiable_addresses"] = verifiable payload["recovery_addresses"] = []map[string]interface{}{ { "value": user.Email, "via": "email", }, } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("ory provider: build create request failed: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := o.httpClient().Do(req) if err != nil { return "", fmt.Errorf("ory provider: create identity request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", fmt.Errorf("ory provider: 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 "", fmt.Errorf("ory provider: decode create identity response failed: %w", err) } slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email) return created.ID, nil } // SignIn은 Kratos Public API의 login API 플로우를 사용해 세션 토큰을 발급합니다. func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { if loginID == "" || password == "" { return nil, fmt.Errorf("ory provider: loginID and password are required") } flowID, err := o.startLoginFlow("") if err != nil { return nil, err } body, _ := json.Marshal(map[string]string{ "identifier": loginID, "password": password, "method": "password", }) loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("ory provider: build login request failed: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := o.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("ory provider: login request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("ory provider: login failed status=%d body=%s", resp.StatusCode, string(respBody)) } var result struct { SessionToken string `json:"session_token"` SessionTokenExpiresAt time.Time `json:"session_token_expires_at"` Session struct { ID string `json:"id"` Identity struct { ID string `json:"id"` } `json:"identity"` } `json:"session"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("ory provider: decode login response failed: %w", err) } if result.SessionToken == "" { return nil, fmt.Errorf("ory provider: empty session token returned") } slog.Info("Ory login successful", "identity_id", result.Session.Identity.ID, "loginID", loginID, "expires_at", result.SessionTokenExpiresAt, ) return &domain.AuthInfo{ SessionToken: &domain.Token{ JWT: result.SessionToken, Expiration: result.SessionTokenExpiresAt, SessionID: result.Session.ID, }, Subject: result.Session.Identity.ID, }, nil } // UserExists는 Kratos Admin API로 loginID 존재 여부를 확인합니다. func (o *OryProvider) UserExists(loginID string) (bool, error) { if loginID == "" { return false, fmt.Errorf("ory provider: loginID is empty") } identityID, err := o.findIdentityID(loginID) if err != nil { return false, fmt.Errorf("ory provider: find identity failed: %w", err) } return identityID != "", nil } // IssueSession은 Ory에서 별도 세션 발급이 필요할 때 사용합니다. (현재 미지원) func (o *OryProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, domain.ErrNotSupported } // InitiateLinkLogin은 Kratos Public API로 링크 로그인 플로우를 시작하고 이메일 전송을 트리거합니다. func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { if loginID == "" { return nil, fmt.Errorf("ory provider: loginID is required") } effectiveLoginID, err := o.resolveEffectiveLoginID(loginID) if err != nil { return nil, err } if err := o.ensureCodeLoginIdentifier(effectiveLoginID); err != nil { return nil, err } init, err := o.submitLoginCodeInit(effectiveLoginID, returnTo) if err == nil { init.LoginID = effectiveLoginID return init, nil } if shouldBootstrapCodeLogin(err) { if ensureErr := o.ensureCodeLoginIdentifier(effectiveLoginID); ensureErr == nil { init, initErr := o.submitLoginCodeInit(effectiveLoginID, returnTo) if initErr == nil { init.LoginID = effectiveLoginID } return init, initErr } else { slog.Warn("Ory code login bootstrap failed", "loginID", effectiveLoginID, "error", ensureErr) } } return nil, err } func (o *OryProvider) resolveEffectiveLoginID(loginID string) (string, error) { if strings.Contains(loginID, "@") { return loginID, nil } identityID, err := o.findIdentityID(loginID) if err != nil { return "", err } if identityID == "" { return "", fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) } fullIdentity, err := o.fetchIdentityFull(identityID) if err != nil { return "", err } if fullIdentity != nil { if emailRaw, ok := fullIdentity.Traits["email"]; ok { if email, ok := emailRaw.(string); ok && email != "" { return email, nil } } } return "", fmt.Errorf("ory provider: email trait missing for loginID=%s", loginID) } func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) { flowID, err := o.startLoginFlow(returnTo) if err != nil { return nil, err } body, _ := json.Marshal(map[string]string{ "method": "code", "identifier": loginID, }) loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("ory provider: build link login request failed: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := o.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("ory provider: link login request failed: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 300 { init, ok := parseKratosLinkLoginResponse(flowID, respBody) if ok { slog.Info("Ory link login initiated with non-2xx response", "loginID", loginID, "flow_id", flowID, "status", resp.StatusCode) return init, nil } return nil, fmt.Errorf("ory provider: link login failed status=%d body=%s", resp.StatusCode, string(respBody)) } var result struct { ExpiresAt time.Time `json:"expires_at"` } _ = json.Unmarshal(respBody, &result) slog.Info("Ory link login initiated", "loginID", loginID, "flow_id", flowID) return &domain.LinkLoginInit{ FlowID: flowID, ExpiresAt: result.ExpiresAt, Mode: "link", }, nil } func parseKratosLinkLoginResponse(flowID string, body []byte) (*domain.LinkLoginInit, bool) { if len(body) == 0 { return nil, false } var parsed struct { ExpiresAt time.Time `json:"expires_at"` State string `json:"state"` Active string `json:"active"` } if err := json.Unmarshal(body, &parsed); err != nil { return nil, false } state := strings.ToLower(parsed.State) active := strings.ToLower(parsed.Active) if strings.Contains(state, "sent") || active == "code" { return &domain.LinkLoginInit{ FlowID: flowID, ExpiresAt: parsed.ExpiresAt, Mode: "link", }, true } return nil, false } func shouldBootstrapCodeLogin(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "has not setup sign in with code") || strings.Contains(msg, "4000035") } type kratosVerifiableAddress struct { Value string `json:"value"` Via string `json:"via"` Verified bool `json:"verified"` Status string `json:"status,omitempty"` } func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error { identityID, err := o.findIdentityID(loginID) if err != nil { return fmt.Errorf("ory provider: find identity failed: %w", err) } if identityID == "" { return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) } identity, err := o.fetchIdentity(identityID) if err != nil { return err } via := "sms" if strings.Contains(loginID, "@") { via = "email" } exists := false existingIndex := -1 addresses := make([]kratosVerifiableAddress, 0, len(identity.VerifiableAddresses)+1) for idx, addr := range identity.VerifiableAddresses { addresses = append(addresses, kratosVerifiableAddress{ Value: addr.Value, Via: addr.Via, Verified: addr.Verified, Status: addr.Status, }) if addr.Value == loginID && addr.Via == via { exists = true existingIndex = idx } } ops := make([]map[string]interface{}, 0, 2) if !exists { ops = append(ops, map[string]interface{}{ "op": "add", "path": "/verifiable_addresses/-", "value": map[string]interface{}{ "value": loginID, "via": via, "verified": true, "status": "completed", }, }) } else { addr := identity.VerifiableAddresses[existingIndex] if !addr.Verified { ops = append(ops, map[string]interface{}{ "op": "replace", "path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex), "value": true, }) } if addr.Status != "" && addr.Status != "completed" { ops = append(ops, map[string]interface{}{ "op": "replace", "path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex), "value": "completed", }) } } if len(ops) == 0 { slog.Info("Ory identity verifiable address already ready", "identity_id", identityID, "loginID", loginID, "via", via) return nil } if err := o.patchIdentity(identityID, ops); err != nil { slog.Warn("Ory identity patch failed, trying full update", "identity_id", identityID, "error", err) } fullIdentity, err := o.fetchIdentityFull(identityID) if err != nil { return err } addresses = make([]kratosVerifiableAddress, 0, len(fullIdentity.VerifiableAddresses)+1) found := false for _, addr := range fullIdentity.VerifiableAddresses { addresses = append(addresses, kratosVerifiableAddress{ Value: addr.Value, Via: addr.Via, Verified: addr.Verified, Status: addr.Status, }) if addr.Value == loginID && addr.Via == via { found = true } } if !found { addresses = append(addresses, kratosVerifiableAddress{ Value: loginID, Via: via, Verified: true, Status: "completed", }) } payload := map[string]interface{}{ "schema_id": fullIdentity.SchemaID, "traits": fullIdentity.Traits, "verifiable_addresses": addresses, } if len(fullIdentity.RecoveryAddresses) > 0 { payload["recovery_addresses"] = fullIdentity.RecoveryAddresses } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) if err != nil { return fmt.Errorf("ory provider: build identity update failed: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := o.httpClient().Do(req) if err != nil { return fmt.Errorf("ory provider: identity update failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("ory provider: identity update failed status=%d body=%s", resp.StatusCode, string(respBody)) } slog.Info("Ory identity updated with verifiable address", "identity_id", identityID, "loginID", loginID, "via", via) return nil } type kratosIdentity struct { VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` } type kratosRecoveryAddress struct { Value string `json:"value"` Via string `json:"via"` } type kratosIdentityFull struct { SchemaID string `json:"schema_id"` Traits map[string]interface{} `json:"traits"` VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"` } func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error { body, _ := json.Marshal(ops) req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) if err != nil { return fmt.Errorf("ory provider: build identity patch failed: %w", err) } req.Header.Set("Content-Type", "application/json-patch+json") resp, err := o.httpClient().Do(req) if err != nil { return fmt.Errorf("ory provider: identity patch failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("ory provider: identity patch failed status=%d body=%s", resp.StatusCode, string(respBody)) } slog.Info("Ory identity patched", "identity_id", identityID, "ops", len(ops)) return nil } func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil) if err != nil { return nil, fmt.Errorf("ory provider: build identity get failed: %w", err) } resp, err := o.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("ory provider: identity get failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body)) } var identity kratosIdentity if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { return nil, fmt.Errorf("ory provider: decode identity failed: %w", err) } return &identity, nil } func (o *OryProvider) fetchIdentityFull(identityID string) (*kratosIdentityFull, error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil) if err != nil { return nil, fmt.Errorf("ory provider: build identity get failed: %w", err) } resp, err := o.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("ory provider: identity get failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body)) } var identity kratosIdentityFull if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { return nil, fmt.Errorf("ory provider: decode identity failed: %w", err) } return &identity, nil } // VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다. func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { if loginID == "" || flowID == "" || code == "" { return nil, fmt.Errorf("ory provider: loginID, flowID and code are required") } body, _ := json.Marshal(map[string]string{ "method": "code", "identifier": loginID, "code": code, }) loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("ory provider: build login code request failed: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := o.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("ory provider: login code request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("ory provider: login code failed status=%d body=%s", resp.StatusCode, string(respBody)) } var result struct { SessionToken string `json:"session_token"` SessionTokenExpiresAt time.Time `json:"session_token_expires_at"` Session struct { ID string `json:"id"` Identity struct { ID string `json:"id"` } `json:"identity"` } `json:"session"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("ory provider: decode login code response failed: %w", err) } if result.SessionToken == "" { return nil, fmt.Errorf("ory provider: empty session token returned") } slog.Info("Ory login code successful", "identity_id", result.Session.Identity.ID, "loginID", loginID, "expires_at", result.SessionTokenExpiresAt, ) return &domain.AuthInfo{ SessionToken: &domain.Token{ JWT: result.SessionToken, Expiration: result.SessionTokenExpiresAt, SessionID: result.Session.ID, }, Subject: result.Session.Identity.ID, }, nil } // GetPasswordPolicy는 Ory 환경에서 사용하는 기본 정책을 반환합니다. func (o *OryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return &domain.PasswordPolicy{ MinLength: 12, Lowercase: true, Uppercase: false, Number: true, NonAlphanumeric: true, MinCharacterTypes: 0, }, nil } // InitiatePasswordReset는 현재 내부 토큰/메일 흐름을 사용하고 있으므로 NO-OP로 둡니다. func (o *OryProvider) InitiatePasswordReset(loginID, redirectUrl string) error { slog.Info("Ory InitiatePasswordReset bypassed (handled by app internal flow)", "loginID", loginID, "redirect", redirectUrl) return nil } // VerifyPasswordResetToken는 내부 토큰 검증 흐름을 사용하므로 아직 구현하지 않습니다. func (o *OryProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { return nil, fmt.Errorf("ory provider: VerifyPasswordResetToken not implemented (internal token flow expected)") } // UpdateUserPassword: Kratos Admin API를 통해 비밀번호를 갱신합니다. func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { if loginID == "" || newPassword == "" { return fmt.Errorf("ory provider: loginID or new password missing") } identityID, err := o.findIdentityID(loginID) if err != nil { return fmt.Errorf("ory provider: find identity failed: %w", err) } if identityID == "" { return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) } patchOps := []map[string]interface{}{ { "op": "add", "path": "/credentials/password/config/password", "value": newPassword, }, } body, _ := json.Marshal(patchOps) req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) if err != nil { return fmt.Errorf("ory provider: build request failed: %w", err) } req.Header.Set("Content-Type", "application/json-patch+json") resp, err := o.httpClient().Do(req) if err != nil { return fmt.Errorf("ory provider: request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("ory provider: password update failed status=%d body=%s", resp.StatusCode, string(respBody)) } slog.Info("Ory password updated via Kratos admin", "identity_id", identityID, "loginID", loginID) return nil } func getenv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환 func (o *OryProvider) findIdentityID(loginID string) (string, error) { u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL)) if err != nil { return "", err } query := u.Query() query.Set("credentials_identifier", loginID) u.RawQuery = query.Encode() req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil) if err != nil { return "", err } resp, err := o.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, 1024)) 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 "", fmt.Errorf("decode response failed: %w", err) } if len(identities) == 0 { return "", nil } return identities[0].ID, nil } func (o *OryProvider) httpClient() *http.Client { if o.HTTPClient != nil { return o.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, }, } } // startLoginFlow는 Kratos Public API에서 login flow ID를 발급받습니다. func (o *OryProvider) startLoginFlow(returnTo string) (string, error) { loginURL := fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL) if returnTo != "" { loginURL = loginURL + "?return_to=" + url.QueryEscape(returnTo) } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, loginURL, nil) if err != nil { return "", fmt.Errorf("ory provider: build login flow request failed: %w", err) } resp, err := o.httpClient().Do(req) if err != nil { return "", fmt.Errorf("ory provider: login flow request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", fmt.Errorf("ory provider: login flow failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { ID string `json:"id"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("ory provider: decode login flow failed: %w", err) } if result.ID == "" { return "", fmt.Errorf("ory provider: empty login flow id") } return result.ID, nil }