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 isAVAILABLE→ create the post referencing the video URN). Requires theLinkedIn-Version: YYYYMMheader +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(recipefeedshare-video) → single binary upload to the returneduploadUrl→POST /v2/ugcPostswithshareMediaCategory: VIDEOreferencing the asset URN. Fewer steps, but LinkedIn is deprecatingugcPosts/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_inonly. - 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), standardclient_id/client_secret— so this one does map cleanly togolang.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 "runkeryx auth linkedinto re-authorize" error, andauth refreshalerts 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):
- ✅ Modern versioned API —
/rest/posts+ Videos API (future-proof; matches our TikTok init→upload→poll shape;LinkedIn-Versionheader). Not legacy. - ✅ 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. - ✅ 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_tokenautomatically if one ever appears. - ✅ Include
PostMeta.Linkin the post commentary (LinkedIn makes in-text URLs clickable).
Still open:
- 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¶
- (review this spec; resolve §9) →
keryx auth linkedin(x/oauth2 capture → read Person URN → store token+expiry; prove the no-refresh expiry path surfaces a clean re-auth error) →linkedinPublisher (init → chunked upload → finalize → poll → /rest/posts)- dry-run →
- live member post end-to-end (real reel ≥75 KB) → docs → MR →
- then build
auth refresh(separate spec) now that all four token shapes are known. ```