Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/request-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
The LinkedIn request headers retain the legacy compatibility pair app 4.1.1209 / build 211700. `X-LI-Track` contains one `osVersion`, the SDK string `36`. These descriptive headers do not establish a native Android session or official-client equivalence. The transport is Go `net/http`.

All LinkedIn wrappers use one authenticated request boundary, which accepts only HTTPS `www.linkedin.com` Voyager API destinations and rejects redirects without following them. The external employer checker remains separate and credential-free. Injected HTTP clients are copied and receive the same redirect policy; injected transports still pass through request validation and header construction.

Mutations receive one application-level send admission, without retry loops, idempotency headers or a body replay function. Go's standard transport can internally retry certain failures on reused connections, including failures before any bytes were written; an admission is not an exact packet count. Custom transports control their own internal behavior. Read retries on rate limits and timeouts remain for compatibility in M1; shared pacing, durable refusal state and retry-policy changes belong to M2.
197 changes: 47 additions & 150 deletions internal/api/client.go
Original file line number Diff line number Diff line change
@@ -1,216 +1,113 @@
// Package api provides an HTTP client for LinkedIn's internal GraphQL API.
//
// LinkedIn's Android app communicates via HTTPS to voyager.linkedin.com using
// a combination of standard HTTP headers and RestLi-encoded query variables.
// This client replicates the required headers so the server treats requests as
// coming from the official Android client.
package api

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"

"github.com/yashiels/linkedin-cli/internal/auth"
"github.com/yashiels/linkedin-cli/internal/restli"
"github.com/yashiels/linkedin-cli/internal/types"
)

const (
baseURL = "https://www.linkedin.com"
graphQLPath = "/voyager/api/graphql"

defaultTimeout = 30 * time.Second
maxRetries = 4
baseBackoff = 1 * time.Second

// Static Android client fingerprint mirroring decompiled APK values.
trackHeader = `{"osName":"Android OS","osVersion":"36","clientVersion":"4.1.1209",` +
`"model":"samsung_SM-S901E","displayDensity":2.625,"displayWidth":1080,` +
`"displayHeight":2340,"osVersion":"BP2A.250605.031.A3","timezoneOffset":2,` +
`"interfaceLocale":"en_US"}`

userAgent = "com.linkedin.android/211700 (Linux; U; Android 16; en_ZA; " +
baseURL = "https://www.linkedin.com"
graphQLPath = "/voyager/api/graphql"
defaultTimeout = 30 * time.Second
maxRetries = 4
baseBackoff = time.Second
legacyAppVersion = "4.1.1209"
legacyAppBuild = "211700"
userAgent = "com.linkedin.android/" + legacyAppBuild + " (Linux; U; Android 16; en_ZA; " +
"SM-S901E; Build/BP2A.250605.031.A3; Cronet/127.0.6533.65)"
)

// Client is a configured LinkedIn API client.
type trackingHeader struct {
OSName string `json:"osName"`
OSVersion string `json:"osVersion"`
ClientVersion string `json:"clientVersion"`
Model string `json:"model"`
DisplayDensity float64 `json:"displayDensity"`
DisplayWidth int `json:"displayWidth"`
DisplayHeight int `json:"displayHeight"`
TimezoneOffset int `json:"timezoneOffset"`
InterfaceLocale string `json:"interfaceLocale"`
}

type Client struct {
http *http.Client
creds auth.Credentials
verbose bool
debug bool
errOut io.Writer
timeout *time.Duration
}

// Option configures a Client.
type Option func(*Client)

// WithTimeout sets the HTTP timeout (default 30 s).
func WithTimeout(d time.Duration) Option {
return func(c *Client) { c.http.Timeout = d }
return func(c *Client) { c.timeout = &d }
}

// WithVerbose enables request/response logging to errOut.
func WithVerbose(v bool) Option { return func(c *Client) { c.verbose = v } }

// WithDebug enables full body logging to errOut.
func WithDebug(v bool) Option { return func(c *Client) { c.debug = v } }

// WithErrWriter sets the writer used for diagnostic output (default os.Stderr).
func WithVerbose(v bool) Option { return func(c *Client) { c.verbose = v } }
func WithDebug(v bool) Option { return func(c *Client) { c.debug = v } }
func WithErrWriter(w io.Writer) Option { return func(c *Client) { c.errOut = w } }

func WithHTTPClient(client *http.Client) Option { return func(c *Client) { c.http = client } }
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
if client != nil {
owned := *client
c.http = &owned
}
}
}

// New creates a Client using the provided credentials.
func New(creds auth.Credentials, opts ...Option) *Client {
c := &Client{
http: &http.Client{
Timeout: defaultTimeout,
// Detect session expiry by catching redirects to login pages or
// self-redirect loops. LinkedIn returns a 302 back to the same URL
// (with set-cookie: li_at=delete me) when li_at has expired.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if strings.Contains(req.URL.Path, "/login") ||
strings.Contains(req.URL.Path, "/authwall") ||
strings.Contains(req.URL.Path, "/uas/login") {
return types.AuthError("session expired — run 'lnk auth login' to refresh")
}
// Self-redirect loop: LinkedIn sends 302 → same URL when li_at expires.
if len(via) > 0 && req.URL.String() == via[len(via)-1].URL.String() {
return types.AuthError("session expired — run 'lnk auth login' to refresh")
}
if len(via) >= 3 {
return types.AuthError("too many redirects — session may be expired, run 'lnk auth login'")
}
return nil
},
},
creds: creds,
errOut: io.Discard,
}
c := &Client{http: &http.Client{Timeout: defaultTimeout}, creds: creds, errOut: io.Discard}
for _, o := range opts {
o(c)
}
if c.timeout != nil {
c.http.Timeout = *c.timeout
}
c.http.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return c
}

// QueryGraphQL executes a LinkedIn GraphQL query by name and ID with the given
// variables. variables must be encodable by the restli package.
//
// It returns the raw JSON response body on success. On rate-limit it retries
// with exponential back-off up to maxRetries times.
func (c *Client) QueryGraphQL(queryName, queryID string, variables interface{}) (json.RawMessage, error) {
varStr, err := restli.Encode(variables)
if err != nil {
return nil, fmt.Errorf("api: cannot encode variables: %w", err)
}

// Build URL.
u, _ := url.Parse(baseURL + graphQLPath)
// Build raw query manually to avoid double-encoding the RestLi variables.
// The RestLi encoder already URL-encodes special characters within values.
u.RawQuery = "queryId=" + url.QueryEscape(queryID) +
"&queryName=" + url.QueryEscape(queryName) +
"&variables=" + varStr

var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(math.Pow(2, float64(attempt-1))) * baseBackoff
c.logf("rate limited, retrying in %s (attempt %d/%d)", wait, attempt, maxRetries)
time.Sleep(wait)
}

req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("api: cannot build request: %w", err)
}

c.injectHeaders(req)

if c.verbose || c.debug {
c.logf("→ GET %s", u.String())
}

resp, err := c.http.Do(req)
if err != nil {
// Unwrap url.Error to check for auth errors from CheckRedirect.
// Auth errors (session expiry) must not be retried.
var urlErr *url.Error
if errors.As(err, &urlErr) {
var authErr *types.LnkError
if errors.As(urlErr.Err, &authErr) && authErr.Code == types.ExitAuth {
return nil, authErr
}
if !urlErr.Timeout() {
return nil, types.NetworkError("GraphQL request failed", err)
}
}
lastErr = types.NetworkError("HTTP request failed", err)
continue
}
defer resp.Body.Close()

if c.verbose || c.debug {
c.logf("← %d %s", resp.StatusCode, resp.Status)
}

if resp.StatusCode == http.StatusTooManyRequests {
lastErr = types.RateLimitError()
continue
}

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("api: unexpected status %d: %s", resp.StatusCode, string(body))
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NetworkError("reading response body", err)
}

if c.debug {
c.logf("body: %s", string(body))
}

return json.RawMessage(body), nil
}

return nil, lastErr
query := "queryId=" + url.QueryEscape(queryID) + "&queryName=" + url.QueryEscape(queryName) + "&variables=" + varStr
return c.readJSON(context.Background(), graphQLReadOperation, baseURL+graphQLPath+"?"+query)
}

// injectHeaders attaches all required LinkedIn API headers to req.
func (c *Client) injectHeaders(req *http.Request) {
csrf := c.creds.CSRFToken
bcookie := c.creds.BCookie
if bcookie == "" {
bcookie = "v=2&00000000-0000-0000-0000-000000000000"
}

cookie := fmt.Sprintf(
"li_at=%s; JSESSIONID=ajax:%s; bcookie=%s; liap=true; lang=v=2&lang=en_US",
c.creds.LiAt, csrf, bcookie,
)

cookie := fmt.Sprintf("li_at=%s; JSESSIONID=ajax:%s; bcookie=%s; liap=true; lang=v=2&lang=en_US", c.creds.LiAt, csrf, bcookie)
track, _ := json.Marshal(trackingHeader{
OSName: "Android OS", OSVersion: "36", ClientVersion: legacyAppVersion,
Model: "samsung_SM-S901E", DisplayDensity: 2.625, DisplayWidth: 1080,
DisplayHeight: 2340, TimezoneOffset: 2, InterfaceLocale: "en_US",
})
req.Header.Set("cookie", cookie)
req.Header.Set("csrf-token", "ajax:"+csrf)
req.Header.Set("x-restli-protocol-version", "2.0.0")
req.Header.Set("x-li-lang", "en_US")
req.Header.Set("x-li-track", trackHeader)
req.Header.Set("x-li-track", string(track))
req.Header.Set("user-agent", userAgent)
req.Header.Set("accept", "application/json")
req.Header.Set("accept-language", "en-US,en;q=0.9")
}

// logf writes a diagnostic message to errOut when verbose/debug is on.
func (c *Client) logf(format string, args ...interface{}) {
fmt.Fprintf(c.errOut, "[lnk] "+format+"\n", args...)
}
Loading
Loading