Skip to content

fix: inspect a pasted Meta token with the app access token and never renew a permanent one (#740) - #744

Merged
hyoshi merged 5 commits into
mainfrom
fix/740-meta-token-inspection
Sep 2, 2026
Merged

fix: inspect a pasted Meta token with the app access token and never renew a permanent one (#740)#744
hyoshi merged 5 commits into
mainfrom
fix/740-meta-token-inspection

Conversation

@hyoshi

@hyoshi hyoshi commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Closes #740.

What

The debug_token inspection added in #726 never worked in the field, and its fallback was actively dangerous for a permanent token. Three facts, established against a real Business Manager system-user token (see the issue thread):

  1. POST /debug_token is refused by Graph (Unsupported post request, subcode 33). The edge is GET-only, so Meta pasted system-user tokens expire in 60–90 days; mureo assumes they never do #726 recorded nothing: no token_type, no token_expires_at, every install left on the 53-day age clock.
  2. GET /debug_token?input_token=T authenticated with T itself is refused ((#100) You must provide an app access token…). A system-user token cannot inspect itself; the call needs the app access token app_id|app_secret of the issuing app.
  3. Graph reports a token issued without an expiry as expires_at: 0 (Debugger: "Expires: Never"). mureo read that as "unknown", so with the app pair stored _should_refresh fell to the age clock and would have exchanged a permanent token for a 60-day one.

Changes

  • mureo/meta_ads/accounts.py: inspect_meta_access_token sends GET with input_token in the query and the app access token in an Authorization: Bearer header; raises the new MetaTokenInspectUnavailable before any request when there is no app pair. Because the token now travels in the URL, a logging.Filter is installed on httpx and on each httpcore.* emitter logger (filters are not inherited by child loggers) for the duration of the call and removed in finally. The result gains never_expires (true only for a literal expires_at: 0). validate_meta_access_token forwards the pair and reports token_inspect_skipped separately from token_inspect_error.
  • mureo/auth.py: MetaAdsCredentials.token_never_expires, loaded only from a JSON boolean (anything else is logged and ignored, since bool("false") is True); _should_refresh returns False on it before the app-pair gate and both clocks. mureo/auth_setup.py carries it as a token-bound field and writes no expiry beside it; mureo/web/env_var_writer.py clears it with the rest of the clock when a token is entered by hand.
  • mureo/web/handlers.py paste route: resolves the app pair from the payload or from the credentials already on disk (read before the save). Warning matrix: no pair anywhere → token_expiry_untracked (new; replaces the misleading token_inspect_failed); Graph refused → token_inspect_failed; ran but returned neither a date nor "never" → token_expiry_unknown; permanent → no warning, token_never_expires: true, auto_refresh: false without auto_refresh_unavailable.
  • Surfaces: status snapshot reports access_token_never_expires and suppresses both expiry warnings for it; the configure card says "This token does not expire" (or "Expiry not checked — see the note below" when no pair was given); the dashboard Meta row states the fact; mureo auth status appends (token does not expire) / (expires YYYY-MM-DD). en/ja strings added; the card intro, guide step 3 and the app-pair hint no longer claim every system-user token expires after 60 days.
  • Docs (docs/authentication.md, docs/architecture.md, daily-check skill), CHANGELOG [Unreleased] / Fixed. Plugins that build MetaAdsCredentials themselves must carry token_never_expires through to get the guarantee; without it the field defaults to False, the pre-Meta debug_token is sent as POST and Graph rejects it, so #726 expiry tracking never records anything #740 behaviour.

Verification

Clean venv, no plugins, no real Graph calls: 10546 passed, 9 skipped; black --check, ruff check clean; mypy mureo/ unchanged (pre-existing missing-stub errors only); node --test tests/js/*.test.js 695/695. Tests pin: GET not POST, the Bearer header, input_token in the query, no request without the pair, the token never reaching an httpx or httpcore.* log record while the call is in flight (and host logging restored after), transport errors scrubbed of both secrets, expires_at: 0never_expires, refresh refused for a permanent token even with the app pair and an expired-looking clock, the non-boolean flag rejected, the paste-route warning matrix, and the env-var writer clearing the flag.

Captured against a mock home carrying token_never_expires: true (light + dark, full page) — attached in the review thread.

…renew a permanent one (#740)

The debug_token inspection added in #726 has never worked in the field.
Three facts, established against a real Business Manager system-user token,
explain why and decide the fix:

1. POST /debug_token is refused by Graph ("Unsupported post request",
   subcode 33). The edge is GET-only. #726 chose POST to keep the token out
   of a URL httpx logs at INFO, so nothing was ever recorded: no token_type,
   no token_expires_at, no expiry on the configure card, and every install
   left on the 53-day age clock the issue was raised to replace.
2. GET /debug_token?input_token=T authenticated with that same token T is
   refused too ("(#100) You must provide an app access token, or a user
   access token that is an owner or developer of the app"). A system-user
   token cannot inspect itself; the call needs the app access token
   "<app_id>|<app_secret>" of the app that issued the token.
3. Graph reports a token issued without an expiry as expires_at: 0 — the
   Access Token Debugger's "Expires: Never". mureo read that as "unknown",
   indistinguishable from an absent field, so with the app pair stored a
   permanent token fell through to the age clock and was exchanged for a
   60-day one: a strict downgrade that then kept the treadmill going.

What changed:

- inspect_meta_access_token sends a GET with input_token in the query and
  the app access token in an Authorization header, takes the app pair as
  keyword arguments, and raises the new MetaTokenInspectUnavailable when
  there is no pair to inspect with — before any request. The inspected token
  now travels in the URL, so the httpx/httpcore loggers are filtered for the
  duration of the call and restored in a finally. The returned dict gains
  never_expires, true only for a literal expires_at: 0.
- validate_meta_access_token forwards the pair and reports
  token_inspect_skipped alongside token_inspect_error: "mureo could not ask"
  and "Meta refused" are different sentences for the operator.
- MetaAdsCredentials gains token_never_expires, loaded from the meta_ads
  section, and _should_refresh returns False on it before the app-pair gate
  and both clocks. auth_setup carries the flag forward as a token-bound
  field (and writes no expiry beside it); the advanced env-var writer clears
  it with the rest of the clock when a token is entered by hand.
- The paste route resolves the app pair from the payload or from what is
  already on disk, read before the save, and passes it to the probe. New
  token_expiry_untracked warning replaces token_inspect_failed when there
  was no pair; token_expiry_unknown is left for an inspection that ran and
  found neither a date nor a "never". A permanent token gets no expiry
  warning, no auto_refresh_unavailable, and token_never_expires on the wire
  and on disk.
- Surfaces: the status snapshot reports access_token_never_expires and
  suppresses both expiry warnings for it; the configure card and the
  dashboard Meta row state the fact instead of a countdown; mureo auth
  status appends "(token does not expire)" or "(expires YYYY-MM-DD)". New
  en/ja strings for the never-expires line, the untracked-expiry warning and
  the dashboard row; the inspect-failed and app-pair copy is reworded, and
  the card intro and setup guide no longer claim every system-user token
  expires after 60 days.
- Docs: authentication.md, architecture.md and the daily-check skill now say
  the app pair is what lets mureo read the expiry as well as renew a 60-day
  token, and that a token issued without an expiry is left alone.
…s given (#740)

With no app ID and secret anywhere, the paste card said two contradictory
things at once: the expiry line rendered
wizard.auth.meta_token_expiry_unknown ("Meta did not report an expiry for
this token"), while the warning right under the save button said mureo had
never asked Meta at all. Only the second is true — without the app access
token there is no debug_token call to make.

renderExpiry now branches on the response's own warning code: when
warnings carries token_expiry_untracked it renders the new
wizard.auth.meta_token_expiry_untracked ("Expiry not checked — see the note
below.") and points at the warning that explains what to enter. The branch
sits ahead of the unknown one because an untracked expiry has no date
either, so the old branch would otherwise keep answering for it.

meta_token_expiry_unknown is unchanged and still used where it is accurate:
an inspection that ran and either failed or came back with no date.
…er-expires flag (#740)

Review follow-ups on the token inspection landed earlier in this branch.

The httpcore half of the log filter was inert. Logger.addFilter is consulted
only for records logged through that exact logger object — unlike handlers
and levels, filters are not inherited by child loggers — and httpcore never
logs through "httpcore" itself: every trace record comes from
httpcore.connection, httpcore.http11, httpcore.http2, httpcore.proxy or
httpcore.socks. A host application running httpcore at DEBUG would still
have got the inspected token in its log. _HTTP_LOGGER_NAMES now lists the
five emitters alongside the two package roots, with the reason written down
where the list is defined, and a parametrised test emits a token-bearing
record through each emitter while the call is in flight: it must not reach a
capturing handler, and the same record must reach it once the call has
returned.

The never-expires flag is now validated at the boundary instead of coerced.
bool("false") is True, so a hand-edited file or a third-party credential
store that wrote the string "false" would have disabled the Meta token
refresh forever, silently. The new auth._coerce_never_expires accepts only a
JSON boolean, logs anything else once and reads it as "not established" —
the pre-#740 behaviour, never worse. Both readers go through it: the
credentials loader and the status snapshot's Meta row, so the dashboard and
the refresh path cannot disagree about what counts as permanent.

Also: a regression test that a transport error quoting the failed request —
the URL carries the inspected token, the Authorization header carries the
app access token — is scrubbed of both, on the inspect path and on the
validate path whose token_inspect_error reaches the configure card. And one
paragraph each in the changelog and the auth docs saying that a credential
store outside mureo's own file has to carry token_never_expires through for
the no-renewal guarantee to apply; without it the field defaults to False.
@hyoshi
hyoshi merged commit 7d03558 into main Sep 2, 2026
13 checks passed
@hyoshi
hyoshi deleted the fix/740-meta-token-inspection branch September 2, 2026 10:41
@hyoshi hyoshi mentioned this pull request Sep 2, 2026
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.

Meta debug_token is sent as POST and Graph rejects it, so #726 expiry tracking never records anything

1 participant