Cross Posting To LinkedIn

I’d like to be able to share these posts with more people. And hopefully potential employers. In order to do that, we’re going to set up a cross posting on LinkedIn.

LinkedIn Applications, Pages, and Scopes

So I falsely expected this to be similar to github’s Personal Access Tokens. I’ve used those before to automate interactions on github. You can setup the permissions you want the token to carry and then it generates a token for your accout. Then for any API access, you can pass the token, and it acts as if you’ve signed into github. Alas, that’s not how LinkedIn does their API interactions. They instead use OAuth2 to handle API interactions. This is a standard protocol that allows applications (or clients) to gain permission from a user after they’ve authenticated. In order to register an application, you need and organization page. Each client application gets a client ID and a client Secret, and we can use those to obtain an access token. So after registering my Application, I have to pre-approve what permissions (or scopes) it’s allowed to do. I also have to designate a redirect URL, where the authentication data will be sent. For the redirect URI, we’re going to set http://localhost:3000

Obtaining an Access Token

Just get the client secret and client id, send them to LinkedIn, and we’re done, right? Not quite. That can work for non-specific API interactions. But because my account will be effected, LinkedIn wants me to sign into my account and approve the scope of my application. You’ve probably done this before if you’ve ever seen the “ Wants to see your email” popup while online. So here’s how this works under the hood.

  1. Obtain an authorization code. We direct the user (me) to a LinkedIn URL with the following values.
    client_id = ""
    client_secret = ""
    redirect_uri = "http://localhost:3000"
    scopes = ["w_member_social", "openid", "profile", "email"]
    with open("oauth.json", "r") as f:
        oidc_data = json.load(f)
        client_id = oidc_data["client_id"]
        client_secret = oidc_data["client_secret"]
    
    # leg 1 open webrowser
    scope = " ".join(scopes)
    url = "https://www.linkedin.com/oauth/v2/authorization?response_type=code" + \
     f"&client_id={client_id}&scope={scope}&redirect_uri={redirect_uri}"
    webbrowser.open(url)

LinkedIn will double check that that client ID, the redirect URI and the scopes all match up to what I’ve already pre-approved. Then, if they do, we’ll see a typical signin page. After we sign in, we’ll be prompted to approve the application’s requested scope. After clicking yes, we’re onto the next step.

  1. LinkedIn will redirect to our provided URI with and authorization code. We’re going to use a python script to capture that code like so:

auth_code = None
class OAuthCallbackHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        global auth_code
        query = parse_qs(urlparse(self.path).query)

        code = query.get("code", [None])[0]
        state = query.get("state", [None])[0]
        error = query.get("error", [None])[0]

        if error:
            print(f"OAuth error: {error}")
        elif code:
            auth_code = code

            self.send_response(200)
            self.send_header("Content-Type", "text/html")
            self.end_headers()
            self.wfile.write(
                b"<h2>Authentication successful! You can close this window.</h2>"
            )
        else:
            self.send_response(400)
            self.end_headers()
            self.wfile.write(b"Missing authorization code")

    def log_message(self, format, *args):
        pass
...

    # leg 2 capture auth code
    server = HTTPServer(("127.0.0.1", 3000), OAuthCallbackHandler)
    server.handle_request()
    server.server_close()
    if auth_code is None:
        print("something went wrong!")
        return

Now that we have that code, we have to send a POST request with the following


    # leg 3 get access token
    token_data = {
        "client_secret": client_secret,
        "client_id": client_id,
        "code": auth_code,
        "grant_type": "authorization_code",
        "redirect_uri": redirect_uri,
    }
    data = urllib.parse.urlencode(token_data).encode("utf-8")
    req = urllib.request.Request(
        access_token_endpoint,
        data=data,
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept": "application/json"
        },
        method="POST"
    )
    tokens = None
    with urllib.request.urlopen(req, timeout=30) as response:
        tokens = json.loads(response.read().decode("utf-8"))
    print(tokens)
    access_token = tokens["access_token"] 

How does the client secret help with that? Well it’s because the client secret lives in the backend server. If you’re doing browser only authentication, there’s a seperate protocol for that, so you don’t have to use the client secret.

Using the Access Token

So now we have the access token, let’s use it to find my profile:

    id_req = urllib.request.Request(
        "https://api.linkedin.com/v2/userinfo",
        headers={
            "Authorization": f"Bearer {access_token}"
        }
    )
    user_data = None
    with urllib.request.urlopen(id_req, timeout=30) as response:
        user_data = json.loads(response.read().decode("utf-8"))
    print(user_data)

And we get the following:

{
    'sub': '9H4mPlqUaD',
    'email_verified': True,
    'name': 'Austin Hauter',
    'locale': {'country': 'US', 'language': 'en'},
    'given_name': 'Austin',
    'family_name': 'Hauter',
    'email': 'hautera@me.com'
}

Great! Now we have our access token working! The next step is to have our blog server use it. Because the access token requires user interaction, and it expires in 60 days, I feel its easier to save the token in a file. Our blog host can simply read the token instead of re-implementing the same OAuth flow in go.

Posting to LinkedIn

So now we’ll create a series of structs to satisfy the LinkedIn API.

type LinkedInDist struct {
	FeedDistribution string   `json:"feedDistribution"`
	TargetEntities   []string `json:"targetEntities"`
	ThirdParty       []string `json:"thirdPartyDistributionChannels"`
}

type LinkedInPost struct {
	Author         string       `json:"author"`
	Commentary     string       `json:"commentary"`
	Visibility     string       `json:"visibility"`
	Distribution   LinkedInDist `json:"distribution"`
	LifeCycleState string       `json:"lifecycleState"`
	IsResharable   bool         `json:"isReshareDisabledByAuthor"`
}

type LinkedInPostRequest struct {
	Post LinkedInPost
	Token string
}

Then we can define this function here:

func (post *LinkedInPostRequest) SendPost() error {
	hc := http.Client{}
	data, err := json.Marshal(post.Post)	
	if err != nil {
		return err
	}
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(
		ctx, http.MethodPost,
		"https://api.linkedin.com/rest/posts",
		bytes.NewReader(data),
	)
	str := fmt.Sprintf("Bearer %s", post.Token)
	req.Header.Set("Authorization", str)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Linkedin-Version", "202601")

	resp, err := hc.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return err
	}
	fmt.Println(string(body))
	return nil
}

The function above will send the post to LinkedIn once we’ve filled out the fields. So our final step is to set that up in the watcher here:

func (p *Posts) update() {
	slog.Info("Scanning for new posts!")
	entries, err := os.ReadDir(p.Path)
	if err != nil {
		panic(err.Error())
	}
	num_posts := len(entries)
	old_posts := p.posts 
	p.posts = make([]Post, num_posts)	
	for i, entry := range entries {
		raw_md, err := os.ReadFile(filepath.Join(p.Path, entry.Name()))
		if err != nil {
			slog.Error("Error reading post!", "Error", err.Error())
			continue
		}
		p.posts[i] = Post{
			Path: "/posts/" + entry.Name(),
			Title: fileNameToDisplayName(entry.Name()),
			Html: mdToHtml(raw_md),
		}
	}
	if len(old_posts) == 0 {
		return
	}
	if len(old_posts) < num_posts {
		new_p, err := getNewPost(old_posts, p.posts)
		if err != nil {
			slog.Info(
				"Warning new post detected but not found!",
				"Old Len", len(old_posts), "New Len", num_posts,
			)
			panic("New post detected but not found")
		}
		slog.Info("New post detected!", "post", new_p )
		if len(p.Token) > 0 {
			blogUrl := fmt.Sprintf(
				"https://austinhauter.me/posts/%s",
				new_p.Path,
			)
			req := NewLIPost(p.AuthorId, new_p.Title, blogUrl, p.Token)
			req.SendPost()
		}
	}
	
	slog.Info("Loaded posts", "count", num_posts)
}

So now our update function will automatically create and send a post if it notices the number of posts increased. Let’s deploy and see how it goes :)