package identityprovider import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/url" "strings" ) const feishuContactUser = "https://open.feishu.cn/open-apis/contact/v3/users/" const feishuBasicBatch = "https://open.feishu.cn/open-apis/contact/v3/users/basic_batch?user_id_type=open_id" func fetchDirectoryName(ctx context.Context, appID, appSecret, openID string) (string, error) { name, _, err := fetchDirectoryNameWithSource(ctx, appID, appSecret, openID) return name, err } func fetchDirectoryNameWithSource(ctx context.Context, appID, appSecret, openID string) (string, string, error) { if !strings.HasPrefix(openID, "ou_") || len(openID) > 128 { return "", "", errors.New("invalid open_id") } payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": appSecret}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuTenantToken, bytes.NewReader(payload)) if err != nil { return "", "", err } req.Header.Set("Content-Type", "application/json") resp, err := upstreamClient.Do(req) if err != nil { return "", "", err } var token struct { Code *int `json:"code"` TenantAccessToken string `json:"tenant_access_token"` } err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) resp.Body.Close() if err != nil || resp.StatusCode != http.StatusOK || token.Code == nil || *token.Code != 0 || token.TenantAccessToken == "" { return "", "", errors.New("tenant token unavailable") } if name, err := fetchBasicName(ctx, token.TenantAccessToken, openID); err == nil { return name, "basic_batch", nil } name, err := fetchContactName(ctx, token.TenantAccessToken, openID) if err != nil { return "", "", err } return name, "contact_user", nil } // basic_batch returns only core identity fields and does not require the user // to be in the application's directory visibility range. Never accept an // uncorrelated name from a batch response. func fetchBasicName(ctx context.Context, token, openID string) (string, error) { payload, _ := json.Marshal(map[string][]string{"user_ids": {openID}}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuBasicBatch, bytes.NewReader(payload)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := upstreamClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var result struct { Code *int `json:"code"` Data struct { Users []struct { UserID string `json:"user_id"` Name string `json:"name"` } `json:"users"` } `json:"data"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil || resp.StatusCode != http.StatusOK || result.Code == nil || *result.Code != 0 { return "", errors.New("basic identity unavailable") } for _, user := range result.Data.Users { if user.UserID == openID && strings.TrimSpace(user.Name) != "" && len(user.Name) <= 256 { return strings.TrimSpace(user.Name), nil } } return "", errors.New("basic identity mismatch") } func fetchContactName(ctx context.Context, token, openID string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, feishuContactUser+url.PathEscape(openID)+"?user_id_type=open_id", nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+token) resp, err := upstreamClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var result struct { Code *int `json:"code"` Data struct { User struct { OpenID string `json:"open_id"` Name string `json:"name"` } `json:"user"` } `json:"data"` } err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) if err != nil || resp.StatusCode != http.StatusOK || result.Code == nil || *result.Code != 0 || result.Data.User.OpenID != openID || strings.TrimSpace(result.Data.User.Name) == "" || len(result.Data.User.Name) > 256 { return "", errors.New("directory identity unavailable") } return strings.TrimSpace(result.Data.User.Name), nil }