Skip to content

0009 — LinkedIn publisher + auth

Status: IN PROGRESS (adapter + keryx auth linkedin built & unit-tested 2026-06-21; modern versioned API, standard 60-day token, loopback capture. Live member-post validation pending the app registration — §9 Q5.) Date: 2026-06-21 Depends on: 0001 §4 (posting), §8 (platform research); 0002 §4.4/§5 (contracts, esp. R-AUTH-3/4); pkg/oauth + the Instagram/YouTube/TikTok adapters already shipped.

1. Goal & scope

Add LinkedIn as the fourth publish.Publisher + keryx auth linkedin, posting a rendered reel as a video share to the authenticated member's feed. Mirror the established seam: additive internal/publish/linkedin, self-registers, no call-site changes. With LinkedIn, post all covers all four platforms.

Why now — it pins the last auth refresh shape. LinkedIn is the only platform that cannot programmatically refresh for a standard app: a 60-day access token and no refresh token unless you're an approved Marketing Developer Platform (MDP) partner. So it forces auth refresh to handle the "can't refresh → detect upcoming expiry, alert, require interactive re-auth" branch — the failure mode none of IG (refresh-in-place), YouTube (durable refresh), or TikTok (rotating refresh) exercises. This spec covers the LinkedIn adapter + auth; the auth refresh subsystem itself is the immediate follow-up (now informed by all four shapes) and is out of scope here (0001 §4.2).

In scope: the member video-share flow, LinkedIn OAuth (access-token-only by default), the platforms.linkedin config block, docs. Out of scope: auth refresh, organization/company-page posting, MDP partnership.

2. Publishing approach

Two API generations exist; §9 Q1 picks one:

  • Modern, versioned (recommended). POST /rest/posts + the Videos API (/rest/videos?action=initializeUpload → chunked 4 MB part uploads, collecting an ETag per part → ?action=finalizeUpload → poll until the video URN is AVAILABLE → create the post referencing the video URN). Requires the LinkedIn-Version: YYYYMM header + X-Restli-Protocol-Version: 2.0.0. This is LinkedIn's going-forward API and matches the init→upload→poll shape we already built for TikTok.
  • Legacy (simpler, deprecated). POST /v2/assets?action=registerUpload (recipe feedshare-video) → single binary upload to the returned uploadUrlPOST /v2/ugcPosts with shareMediaCategory: VIDEO referencing the asset URN. Fewer steps, but LinkedIn is deprecating ugcPosts/assets.

Author = the member's Person URN (urn:li:person:{id}), obtained from OpenID Connect /v2/userinfo (sub claim) — see §3. Caption: PostMeta.Text (+ hashtags) → the post commentary. Link can go in the commentary (LinkedIn makes URLs clickable in text) — confirm in §9. PostResult{ID: postURN, URL: ...} from the x-restli-id / returned URN.

Video requirements (matters for live test)

MP4, 3 s – 30 min, 75 KB – 500 MB. ⚠️ Our current silent test reel is 67 KB — below the 75 KB floor, so the live validation needs a real (heavier) reel (the blog content Matt offered), not the synthetic fixture.

3. Auth (LinkedIn OAuth) — the no-refresh case

Scopes: w_member_social (post on behalf of the member) + openid + profile (to read the Person URN via /v2/userinfo). Products to add in the LinkedIn developer portal: "Share on LinkedIn" (grants w_member_social) + "Sign In with LinkedIn using OpenID Connect" (grants openid/profile + userinfo). Both are self-serve, but the app must be linked to a verified LinkedIn Company Page — that's the registration long pole (no per-post audit like TikTok).

Tokens (the headline):

  • access token ≈ 60 days. A standard app gets no refresh token. The token exchange returns access_token + expires_in only.
  • refresh token only for MDP partners (refresh_token + refresh_token_expires_in ≈ 365 days, non-rotating — the TTL counts down from first issuance and is not reset on use; a hard yearly re-auth wall).
  • Exchange/refresh endpoint: POST https://www.linkedin.com/oauth/v2/accessToken (x-www-form-urlencoded), standard client_id/client_secret — so this one does map cleanly to golang.org/x/oauth2 (unlike TikTok). PKCE optional.

Capture (§9 Q2). LinkedIn redirect URIs are officially https, but http://localhost:<port> works in practice for many. Try the plain-http loopback capture first (like YouTube/TikTok via oauth.FreeHTTPLoopback); if LinkedIn rejects it, fall back to an https self-signed loopback (the Meta path, oauth.FreeHTTPSLoopback) or a hosted redirect. Resolve at implementation.

keryx auth linkedin stores the access token (the usable secret) + its expiry, the refresh token if present, and writes platforms.linkedin.{author_urn, enabled}.

3.1 MDP path (documented; not pursued now)

Refresh tokens require approval into LinkedIn's Marketing Developer Platform (the Advertising / Marketing API Program), not the self-serve "Share on LinkedIn" product. What it takes:

  • Apply for the Advertising API / Marketing API product on the app (Products tab → request access). The app must be linked to a verified Company Page.
  • LinkedIn reviews and approves into a Development Tier (sandbox-like: up to ~5 ad accounts, limited), then a separate review for the Standard Tier (production). You submit a business use case and agree to the Marketing API terms.
  • It grants refresh tokens (60-day access / 365-day non-rotating refresh, so no per-post re-auth — closer to YouTube) plus the org/ads APIs (rw_organization_admin, w_organization_social, rw_ads, …).

Why we're not pursuing it now: MDP is built around advertising / marketing automation use cases. keryx posting a personal blog reel via w_member_social is not an ads use case, so approval is unlikely and the program is overkill for us. The 60-day re-auth + alert path (§4) is the pragmatic fit — and it's the exact auth refresh branch we want to validate. The adapter is built so that if the token exchange ever returns a refresh_token (i.e. MDP is later granted), it's stored and used automatically — no rework, just a config/approval change. Revisit MDP only if unattended LinkedIn posting becomes painful enough to justify the approval effort, or if we add Company-Page (organization) posting (which needs w_organization_social and therefore MDP anyway).

4. Token store & the auth refresh implication

Reuse oauth.Store, but LinkedIn needs to persist an expiry alongside the token (so auth refresh / --dry-run can warn before the 60-day wall). Resolution:

  • Standard app (default): store access_token + access_token_expires_at. The publisher uses the access token directly; if it's expired/near-expiry it cannot auto-refresh → fail with a clear "run keryx auth linkedin to re-authorize" error, and auth refresh alerts ahead of expiry (R-AUTH-4) rather than rotating. This is the branch LinkedIn validates.
  • MDP app (if ever approved): also store the refresh_token; the publisher mints from it via x/oauth2 (non-rotating, so no write-back needed — closer to YouTube), until the 365-day wall forces re-auth.

This completes the four-shape matrix that auth refresh (the next spec) must cover: IG refresh-in-place · YouTube durable-refresh · TikTok rotating-refresh · LinkedIn no-refresh / re-auth + alert.

5. Config & secrets

platforms:
  linkedin:
    enabled: false        # `keryx auth linkedin` sets true on success
    client_id: ""         # LinkedIn app client id (non-secret)
    author_urn: ""        # set by `keryx auth linkedin` (urn:li:person:xxxx)
    api_version: "202606" # LinkedIn-Version header (YYYYMM); bump as LinkedIn rolls
    # access_token + access_token_expires_at (+ refresh_token if MDP) are written
    # here only as the headless/CI fallback when no keychain exists.

Secrets (never committed): LINKEDIN_CLIENT_SECRET (env), LINKEDIN_ACCESS_TOKEN (CI/manual override). client_id/author_urn are non-secret config.

6. Package layout & integration

internal/publish/linkedin/
  linkedin.go     # Publisher: init()→Register, New, Name, Publish (member video share)
  auth.go         # RunAuth(linkedin) — x/oauth2 over pkg/oauth; reads Person URN; stores token+expiry
  tokenstore.go   # oauth.Store (env→keychain→config), access-token keyed + expiry
  *_test.go       # faked httpDoer; upload/post flow + caption + token/expiry units
  *_integration_test.go  # INT_TEST=1 + LI_LIVE_POST=1, real member post (needs a ≥75KB reel)

Wiring (mirrors TikTok): blank-import in pkg/cmd/post/main.go; case "linkedin" in pkg/cmd/auth/main.go; seed platforms.linkedin in the init config asset. Inject an httpDoer so the multi-step upload is unit-tested without the network.

7. Contracts honoured

R-POST-1 (dry-run validates auth/expiry + video vs limits), R-POST-2 (refuse unless approved), R-POST-3 (idempotent ledger via post URN), R-POST-5..8 (post all), R-AUTH-1 (interactive capture), R-AUTH-4 (alert before the non-refreshable token expires) — LinkedIn is the concrete driver for the alert path, as TikTok was for R-AUTH-3.

8. Testing

TDD: fake httpDoer scripts initializeUpload → part PUT (ETag) → finalizeUpload → status poll → /rest/posts (or the legacy register→upload→ugcPosts), plus the OAuth exchange and the /userinfo Person-URN read. Unit-test caption mapping, the chunking/ETag maths, expiry handling (near-expiry → clear re-auth error), and token precedence. One env-gated *_integration_test.go does a real member post — using a real ≥75 KB reel, since the synthetic fixture is too small. A godog scenario covers keryx auth linkedin + keryx post linkedin --dry-run.

9. Questions

Resolved in review (2026-06-21):

  1. Modern versioned API/rest/posts + Videos API (future-proof; matches our TikTok init→upload→poll shape; LinkedIn-Version header). Not legacy.
  2. Capture: plain-http loopback first (like YouTube/TikTok via oauth.FreeHTTPLoopback), https self-signed loopback fallback if LinkedIn rejects http on the redirect. Confirm against the portal at implementation.
  3. Standard 60-day token now; no MDP. Accept no refresh token → re-auth + alert (the branch we're validating). MDP requirements documented in §3.1 for a future decision; the adapter stores/uses a refresh_token automatically if one ever appears.
  4. Include PostMeta.Link in the post commentary (LinkedIn makes in-text URLs clickable).

Still open:

  1. App registration (long pole, you). LinkedIn app linked to a verified Company Page, add "Share on LinkedIn" + "Sign In with LinkedIn using OpenID Connect" products, scopes w_member_social/openid/profile. Need the client id + client secret + a registered redirect.

10. Phased plan

  1. (review this spec; resolve §9) →
  2. keryx auth linkedin (x/oauth2 capture → read Person URN → store token+expiry; prove the no-refresh expiry path surfaces a clean re-auth error) →
  3. linkedin Publisher (init → chunked upload → finalize → poll → /rest/posts)
  4. dry-run →
  5. live member post end-to-end (real reel ≥75 KB) → docs → MR →
  6. then build auth refresh (separate spec) now that all four token shapes are known. ```