Skip to content

fix(client): stop a rejected credential looking like a missing page - #87

Merged
willkg merged 3 commits into
mainfrom
scope-401-hint
Aug 21, 2026
Merged

fix(client): stop a rejected credential looking like a missing page#87
willkg merged 3 commits into
mainfrom
scope-401-hint

Conversation

@willkg

@willkg willkg commented Aug 21, 2026

Copy link
Copy Markdown
Member

Closes #86.

The issue asked for a scope-mismatch 401 to say so rather than printing raw. Measuring the auth failures to build that turned up one the issue had wrong, and it is worse than the one it described.

A rejected credential is a 404 on every v2 route

Not 401, not 403. markfluence reads pages over v2, so a revoked token made read answer:

  ✗ page 2848423944 not found

about a page that exists. The obvious next move — go and check the page id — is wrong for every id, and nothing in the output hints otherwise. --json said "code": "NOT_FOUND", so a script branching on it drew the same wrong conclusion.

It is distinguishable, but only by the title. Every genuine v2 404 names what it could not find; the authentication one does not. Measured against four routes:

request title
missing page, good credentials Cannot find a page with id [999999999999]
missing folder, good credentials Content with id: [999999999999] not found
missing page's properties, good credentials Could not find page with id [999999999999]
existing page, bad credentials Not Found

HTTPError.RejectedCredential matches the bare title, and notFound gates the three …OrNil helpers on it so they stop reading a refused credential as "absent". jsonout.CodeFor asks the same question before its status switch, so --json now reports AUTH. Same command, after:

  ✗ GET https://api.atlassian.com/ex/confluence/…/wiki/api/v2/pages/2848423944?body-format=storage: HTTP 404: {"errors":[{"status":404,"code":"NOT_FOUND","title":"Not Found","detail":null}]}
    hint: the credentials were rejected. Check CONFLUENCE_USERNAME and CONFLUENCE_TOKEN -- this is what a wrong or revoked token returns, and on a v2 route it arrives as a 404 rather than an auth status.

A genuine missing page still reports page 999999999999 not found, unchanged.

The full status table, which the previous docs got wrong

docs/confluence/api.md claimed a bad password returns 401. It does not. Re-measured:

what is wrong route status body
token lacks the scope via gateway 401 {"code":401,"message":"Unauthorized; scope does not match"}
scoped token sent to the site domain site domain 401 Tomcat HTML, no JSON at all
credentials wrong, revoked, or absent v1 403 caller cannot access Confluence (/user/current) or Current user not permitted to use Confluence (/search)
credentials wrong, revoked, or absent v2 404 bare "title":"Not Found"

The two v1 phrasings differ and neither is a substring of the other, so both are matched. A 403 is the credential failure on v1 rather than a permission failure — sending no Authorization header at all returns the identical body.

Design

Three rules, each of which is the difference between a helpful hint and a confidently wrong one.

Matched on the response body, never deduced from the status. The same 401 arrives for two unrelated reasons and the same 404 for two more. Detection lives where HTTPError is built rather than at call sites, so every command gets it rather than whichever ones remembered.

Appended, never substituting. The status, method, URL and body are all still printed. A shape can arrive for a reason not listed here, and a reader being misdirected needs everything they had before the hint existed. A test asserts all four survive in every case.

Silent when unsure. A 403 that is not one of the two measured phrasings gets nothing, because that is what a genuine permission denial looks like and telling someone to reissue a working token is worse than saying nothing. Same for a plain JSON 401, an HTML 401 from the gateway, and an unrecognised 404.

If Atlassian rewords any marker the hint stops appearing rather than becoming wrong, which is the right way for this to fail.

Testing

Eleven cases over HTTPError.Error(), every body a real recorded response rather than a plausible invention — the point of matching on the body is that the shapes are what they are. Both the firing cases and the deliberately-silent ones are pinned, since a hint that grows to cover a genuine 404 is the regression that matters. Verified end-to-end against the live API with a deliberately bad token, and a genuine 404 confirmed unchanged.

Example run:

❯ ./bin/markfluence read 2848423944
  ✗ GET https://api.atlassian.com/ex/confluence/d8febd08-c6e9-4c03-9c13-db37c2369ce5/wiki/api/v2/pages/2848423944?body-format=storage: HTTP 401: {"code":401,"message":"Unauthorized; scope does not match"}
    hint: the API token is valid but carries no scope for this call. Scopes are fixed when a token is issued, so this needs a new token rather than an edit to this one -- the list markfluence needs is in README.md.

Notes for review

  • notFound is a behaviour change, not just messaging. The …OrNil helpers previously returned (nil, nil) for any 404. They now propagate a credential rejection as an error. That is the point, but it means a command that treats "absent" as a normal outcome now sees an error where it used to see nothing.
  • The read example above is the one I reproduced; create's page_id validation and children go through the same helpers, so they had the same hole.

willkg added 3 commits August 21, 2026 08:32
Closes #86.

The issue asked for a scope-mismatch 401 to say so instead of printing
raw. Measuring the auth failures to build that turned up a worse one the
issue had wrong: a rejected credential is a **404** on every v2 route,
not a 401 or a 403. markfluence reads pages over v2, so a revoked token
made `read` answer "page 2848423944 not found" about a page that exists,
and the obvious next move -- go and check the page id -- is wrong for
every id.

It is distinguishable, but only by the title: every genuine v2 404 names
what it could not find ("Cannot find a page with id [...]", "Content
with id: [...] not found") and the authentication one says bare "Not
Found". RejectedCredential matches that, and notFound gates the three
OrNil helpers on it so they stop reading a refused credential as
"absent".

HTTPError.Error() now appends a hint for that case, for the scope
mismatch, and for a scoped token sent to the site domain. Every one
matches on the response body rather than deducing from the status, since
the same 401 arrives for two unrelated reasons. The hint is appended to
the status and body, never replacing them: a shape can still arrive for
a reason not listed, and a misdirected reader needs what they had
before.

A 403 that is not one of the two measured credential phrasings gets
nothing. That is what a genuine permission denial looks like, and
telling someone to reissue a working token is worse than silence.
CodeFor mapped a 404 to CodeNotFound by status alone, so the v2
credential rejection reached a --json consumer as not_found -- the same
misdirection as the human path, in the field a script branches on. Ask
RejectedCredential before the status switch.
The section written yesterday guessed that a bad password returns 401.
It does not: a rejected credential is 403 on v1 (in two different
phrasings) and 404 on v2. Replace the guess with the measured table, add
the titles that tell a genuine v2 404 from the authentication one, and
add a fourth entry to the traps list -- an auth failure can arrive
wearing another status, so "absent" is not believable until the
credentials are known to have reached the API.
@willkg
willkg merged commit f08ae16 into main Aug 21, 2026
1 check passed
@willkg
willkg deleted the scope-401-hint branch August 21, 2026 12:48
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.

Explain a scope-mismatch 401 instead of printing it raw

1 participant