Skip to content

0008 — TikTok publisher + auth

Status: IN PROGRESS (Direct Post adapter and keryx auth tiktok (loopback capture) built & unit-tested; token model = mint-on-demand. Live SELF_ONLY validation pending the sandbox app setup — §9 Q4.) Date: 2026-06-19 (auth + token model added 2026-06-21) Depends on: 0001 §4 (posting), §8.3 (platform research); 0002 §4.4/§5 (contracts, esp. R-AUTH-3); pkg/oauth + the YouTube/Instagram adapters already shipped.

1. Goal & scope

Add TikTok as the third publish.Publisher + keryx auth tiktok, posting a rendered reel via the Content Posting API (Direct Post). Mirror the established seam: additive internal/publish/tiktok package, self-registers, no call-site changes. TikTok is the hardest platform and the one that most shapes auth refresh — its refresh token rotates on every use (must be persisted each refresh), which is exactly the case auth refresh (R-AUTH-3) must handle.

In scope: the Direct Post upload flow, TikTok OAuth (with rotating-refresh-token storage), the platforms.tiktok config block, docs. Out of scope: auth refresh itself (this de-risks its design), scheduling, the studio.

2. Publishing approach (Content Posting API — Direct Post)

Direct Post publishes straight to the account (vs. "upload to inbox/draft"). Flow:

  1. POST /v2/post/publish/creator_info/query/mandatory preflight. Returns the creator's allowed privacy_level_options, plus interaction toggles (comment/duet/stitch_disabled) and max_video_post_duration_sec. We must read this and pick a privacy_level from the returned options (can't hardcode).
  2. POST /v2/post/publish/video/init/ — open the upload. Use source=FILE_UPLOAD with chunked upload (avoids the URL-domain verification that PULL_FROM_URL needs). Body carries post_info (title/caption, privacy_level, interaction flags) + source_info (video_size, chunk_size, total_chunk_count). Returns a publish_id + an upload_url.
  3. PUT the file to upload_url in chunks (Content-Range per chunk).
  4. POST /v2/post/publish/status/fetch/ — poll publish_id until PUBLISH_COMPLETE (or a failure status), like the IG container poll.

Map from publish.PostMeta: Text(+Hashtags) → post_info.title (the caption; TikTok has no separate description — front-load it, ~2,200 chars). Link is not clickable in-caption (note for the social-copy layer). PostResult{ID: publish_id, URL: ...} — TikTok's response may not give a public URL until processed; capture what's available (publish id is the idempotency key regardless).

Audit gate (like YouTube's private cap)

Until the app passes TikTok's mandatory audit, an unaudited app can only post SELF_ONLY (private). So default privacy_level to a config value that is SELF_ONLY until audited, intersected with the privacy_level_options returned by creator_info (step 1 wins — if the API doesn't offer the configured level, fall back to SELF_ONLY and warn). --dry-run reports the effective level.

3. Auth (TikTok OAuth) — the rotating refresh token

Scopes: video.publish + user.info.basic. Tokens:

  • access token ≈ 24h, refresh token ≈ 365 days but ROTATES on every refresh — each refresh returns a new refresh token that must be persisted immediately, or the old one is dead and you're locked out. This is the headline difference from IG (refresh-in-place) and YouTube (durable, non-rotating), and the concrete driver for R-AUTH-3.
  • Capture = loopback auto-capture (resolved; corrected 2026-06-21). An earlier draft assumed TikTok rejects loopback and chose a hosted callback + paste. That's the Web app constraint — the Desktop app type (which keryx is) requires loopback: redirect host localhost/127.0.0.1, any port (wildcard * supported), plain http allowed. So we register http://127.0.0.1:*/callback/ and reuse the same plain-http loopback server as YouTube (oauth.FreeHTTPLoopback + the Capturer) — the code is captured automatically, with stdin paste as the headless/remote fallback. The hosted keryx.phpboyscout.uk/oauth/tiktok/ page is no longer needed and was removed. No domain verification (that's only for pull_by_url; we FILE_UPLOAD).
  • Protocol driven directly (resolved, not x/oauth2). TikTok deviates from RFC 6749/7636 enough that a thin bespoke client is cleaner than fighting x/oauth2: the token endpoint (https://open.tiktokapis.com/v2/oauth/token/) takes client_key (not client_id), form-encoded, and reports errors inline as {error, error_description} with HTTP 200; and PKCE code_challenge is the hex of SHA256(verifier) (not base64url), method S256. tokens.go builds the authorize URL + does the exchange/refresh; capture stays on pkg/oauth. (We still use oauth2.GenerateVerifier() for the verifier.)

keryx auth tiktok stores the refresh token via oauth.Store (env→keychain→config, account tiktok-refresh-token) and writes platforms.tiktok.{open_id, enabled}.

Token model — mint-on-demand (resolved)

The refresh token is the durable secret; there is no stored access token. tiktok.New (per keryx post) mints a fresh ~24h access token from the refresh token and persists the returned refresh token immediately when it differs (TikTok rotates conditionally — "use the newly-returned token if it differs"). The rotation persist is injected as a saveRefresh seam so it's unit-tested without a live keychain. This per-post mint-and-persist is the concrete shape auth refresh (R-AUTH-3) generalises across platforms.

4. Token store & the auth refresh implication

Reuse oauth.Store. Critical: any code path that refreshes a TikTok token must immediately Save the new refresh token returned alongside the access token. For the adapter's per-post token minting, that means the refresh isn't a pure read — it writes back. This needs a writable store at post time (config/ keychain locally; the GitLab-variable/secret-manager write-back in CI — 0001 §4.2). We surface the rotation seam now so auth refresh consumes it uniformly across platforms.

5. Config & secrets

platforms:
  tiktok:
    enabled: false        # `keryx auth tiktok` sets true on success
    client_key: ""        # TikTok app client key (note: not "client id")
    open_id: ""           # set by `keryx auth tiktok` (the creator's open_id)
    privacy: SELF_ONLY    # SELF_ONLY until audited; then PUBLIC_TO_EVERYONE etc.
    # refresh_token written here only as the headless/CI fallback (no keychain)

Secrets (never committed): TIKTOK_CLIENT_SECRET (env), TIKTOK_REFRESH_TOKEN (CI/manual override, env). client_key is non-secret config.

6. Package layout & integration

internal/publish/tiktok/
  tiktok.go       # Publisher: init()→publish.Register, New, Name, Publish (Direct Post flow)
  auth.go         # RunAuth(tiktok) — x/oauth2 (custom Endpoint, client_key, PKCE) over pkg/oauth
  tokenstore.go   # oauth.Store (env→keychain→config), refresh-token keyed
  *_test.go       # faked httpDoer; flow + mapping + token-precedence units
  *_integration_test.go  # INT_TEST=1 + TT_LIVE_POST=1, real SELF_ONLY post

Wiring (mirrors YouTube): blank-import in pkg/cmd/post/main.go; case "tiktok" in pkg/cmd/auth/main.go; seed platforms.tiktok in the init config asset.

The upload flow is raw HTTP (no official Go SDK), so — like Instagram — inject an httpDoer + a sleep so the multi-step flow is unit-tested without the network.

7. Contracts honoured

R-POST-1 (dry-run validates auth + video vs limits + effective privacy, posts nothing), R-POST-2 (refuse unless approved), R-POST-3 (idempotent ledger via publish_id), R-POST-5..8 (post all), R-AUTH-1 (interactive capture), R-AUTH-3 (persist the rotated refresh token immediately), R-AUTH-4 (refresh failure alerts — design only here).

8. Testing

TDD: fake httpDoer scripts creator_info → init → chunk PUT → status poll, and the OAuth token exchange/refresh (asserting the rotated refresh token is persisted). Unit-test caption mapping, privacy intersection (config ∩ creator_info options → SELF_ONLY fallback), chunking maths, and token precedence. One env-gated *_integration_test.go does a real SELF_ONLY post. A godog scenario covers keryx auth tiktok + keryx post tiktok --dry-run.

9. Questions

Resolved in review (2026-06-19):

  1. Capture = loopback auto-capture (corrected 2026-06-21; superseded the hosted-callback + paste draft). TikTok Desktop apps require a loopback redirect, so register http://127.0.0.1:*/callback/ and reuse the YouTube loopback flow; paste remains the headless fallback. See §3.
  2. Privacy SELF_ONLY until audited, intersected with creator_info options (fall back to SELF_ONLY + warn).
  3. Caption: Text (+ hashtags) → post_info.title; drop the link from the caption (not clickable) — keryx social owns TikTok-appropriate copy.

Still open:

  1. App registration — long pole, start now. TikTok for Developers app (sandbox) + Content Posting API (Direct Post on) + video.publish/ user.info.basic + the mandatory audit (SELF_ONLY until passed). Weeks of lead time. Register a Desktop loopback redirect (http://127.0.0.1:*/callback/) and supply the client key + client secret. No domain verification needed (FILE_UPLOAD, not pull_by_url).
  2. x/oauth2 fit (resolved). Driven directly via a thin bespoke client (tokens.go) rather than oauth2.Config — TikTok's client_key, inline error envelope, and hex PKCE challenge make a shim more code than benefit. Capture stays on pkg/oauth (loopback + paste). See §3.

10. Phased plan

  1. (review this spec; resolve §9) →
  2. keryx auth tiktok (capture via pkg/oauth → store refresh token; prove rotation persists) →
  3. tiktok Publisher (creator_info → init → chunked upload → poll) + dry-run →
  4. live SELF_ONLY post end-to-end → docs → MR.
  5. (parallel, you) TikTok app + audit to lift the SELF_ONLY cap.