Skip to content

feat(client): support scoped service-account API tokens via the api.atlassian.com gateway - #53

Merged
willkg merged 2 commits into
mainfrom
gateway-scoped-tokens
Aug 4, 2026
Merged

feat(client): support scoped service-account API tokens via the api.atlassian.com gateway#53
willkg merged 2 commits into
mainfrom
gateway-scoped-tokens

Conversation

@willkg

@willkg willkg commented Aug 4, 2026

Copy link
Copy Markdown
Member

Lets markfluence authenticate with an Atlassian scoped API token — the kind a service account gets — so publishing from CI runs as the service account rather than as whichever individual's personal token is in the secret store.

Fixes #52.

This is a URL change, not an auth change

Basic auth already works with a scoped token. What fails is the URL: a scoped token is rejected with a 401 against the site domain and must go through https://api.atlassian.com/ex/confluence/{cloudId}. The path suffixes are identical under the gateway, so no call site in internal/client needed touching.

Why the client now carries two bases

The gateway URL can't simply replace CONFLUENCE_URL, because the site URL is still needed for things that aren't API requests:

  • convert.MdToConfluence bakes the base into rewritten doc links, so a wrong value publishes api.atlassian.com links into the page — a silent defect that persists long after the run.
  • Printed page URLs in update/create/fix/info.

So BaseURL() is now strictly where requests go, and SiteURL() is always the site. Both converter call sites carry a comment saying which one and why.

Configuration

Setting Flag Env / .env
cloud ID --cloud-id CONFLUENCE_CLOUD_ID

With no cloud ID, both bases are the site and behavior is unchanged — that's the back-compat guarantee for existing personal-token users and for Data Center, which has no cloud ID at all. A URL-ish cloud ID is rejected at config time, since pasting a whole gateway URL would otherwise surface as an opaque 404.

New/Resolve moved to Config/Options structs rather than growing a fourth positional string.

resolveNext replaces the raw baseURL + _links.next concatenation in ListContentProperties. Appending is the correct behavior (next is a site-relative absolute path, and url.ResolveReference would silently drop /ex/confluence/{cloudId}), but it now also guards the converse case where an echoed prefix would be doubled.

Verified against the live API

Gateway routing accepts an ordinary personal token, so info was testable end-to-end before the service account exists. Both open questions from the design notes are answered:

  • _links.base returns the site URL through the gateway (https://<site>.atlassian.net/wiki), so the existing _links.base-preferring logic needs no inversion. info output is identical with and without --cloud-id.
  • v1 endpoints work through the gateway — author display names resolved, which is the v1 /wiki/rest/api/user call.

Still unverified

The v1 attachment upload. Atlassian documents classic scopes for v1 vs granular for v2 while warning against mixing sets, so a scope gap would surface there, and it's load-bearing for any page with images. A personal token is unscoped, so testing it now would prove nothing about scopes — this has to wait for the real credential. First real run should be update --dry-run on a page with an image.

Deliberately out of scope

  • OAuth 2.0. Atlassian's service-account flow is client_credentials (2LO) and so is CI-usable, but the client_secret that mints the 60-minute token is itself long-lived — two secrets, same blast radius — and it needs this same gateway change anyway.
  • Bearer auth. The bearer value is the scoped token, same secret in a different header, so it grants no capability basic auth lacks. Its only payoff is not needing the service account's email. Small follow-up.

Test notes

Covered: resolveNext (all branches), the two bases, cloud-ID precedence and validation, and pageURL in gateway mode.

Not covered: the one-line MdToConfluence(mf, c.SiteURL(), ...) wiring. An end-to-end assertion would require making the gateway prefix injectable so a test server could stand in for api.atlassian.com — more machinery than the risk seemed to warrant, but happy to add it.

Design notes in _plans/017_gateway-scoped-tokens.md.

willkg added 2 commits August 4, 2026 09:03
…is set

A scoped API token -- the kind an Atlassian service account gets -- is rejected
with a 401 against the site domain and must go through
https://api.atlassian.com/ex/confluence/{cloudId} instead. Basic auth already
works with such a token, so this is purely a URL change.

Split the client's single base in two: baseURL is where requests go (the gateway
when --cloud-id / CONFLUENCE_CLOUD_ID is set, else the site) and siteURL is
always the Confluence site. Anything a reader sees resolves against siteURL --
printed page URLs and, critically, the base handed to convert.MdToConfluence,
since rewritten links are published into the page itself and would otherwise
carry the gateway host permanently.

The path suffixes are identical under the gateway, so no call site changes. With
no cloud ID both bases are the site and behavior is unchanged, which is what an
unscoped personal token and any Data Center site need.

New and Resolve take Config/Options structs rather than growing another
positional string argument; the fields are URL-ish or secret and would transpose
too easily unnamed.

resolveNext replaces the raw baseURL + _links.next concatenation. Appending is
correct -- next is a site-relative absolute path, and url.ResolveReference would
silently drop the /ex/confluence/{cloudId} segment -- but it now also guards the
converse, where an echoed prefix would be doubled.

Verified with info through the gateway: _links.base returns the site URL, so the
existing _links.base-preferring logic needs no inversion, and v1 endpoints work
there too (author name lookup succeeded).

Refs #52
Add a "Scoped tokens and service accounts" section covering why a scoped token
needs CONFLUENCE_CLOUD_ID, how to find the cloud ID, and the scopes markfluence
requires. Note in the GitHub Actions section that the cloud ID belongs in a
repository variable rather than a secret -- it isn't sensitive.

Refs #52
@willkg
willkg merged commit 2bb6997 into main Aug 4, 2026
1 check passed
@willkg
willkg deleted the gateway-scoped-tokens branch August 4, 2026 13:14
@willkg
willkg restored the gateway-scoped-tokens branch August 4, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Atlassian scoped API tokens (service accounts) via the api.atlassian.com gateway

1 participant