feat: Thingiverse v2 auth/token manager#224
Conversation
- openforge/thingiverse/auth.py: TokenManager with password login (2FA-aware via session cookies), refresh-token persistence to a 0600 dotfile outside the repo, transparent access-JWT refresh with exp-claim checking, whoami verification, logout - bin/tv_auth: click CLI (login/status/logout) with -v/-q ladder; password prompted via hidden input, never stored - tests/test_thingiverse_auth.py: 25 tests, session injected at our boundary (no third-party mocking) - docs/thingiverse-api-v2.md: v2 API reference extracted from the official OpenAPI spec (from the earlier reverse-engineering pass) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
devonjones
left a comment
There was a problem hiding this comment.
Code Review (Claude)
This review focuses on auth-logic correctness, API contract fidelity against
docs/thingiverse-api-v2.md, and house style. Complexity, error-handling
depth, test coverage, resource leaks, dead code, logging, and credentials
hygiene are covered by other reviewers and are intentionally not duplicated
here.
Critical Issues
1. bin/tv_auth is committed non-executable, breaking the documented usage.
$ git ls-files -s bin/tv_auth bin/db_update
100644 ... bin/tv_auth <- no exec bit
100755 ... bin/db_update <- exec bit (every other bin/ tool)
Every other script in bin/ is mode 100755. tv_auth is 100644, so
bin/tv_auth login (as documented in the PR body and the module docstring)
will fail with "Permission denied" on a fresh checkout — it only works via
python bin/tv_auth .... This directly violates the Unix-tooling convention
in openforge/CLAUDE.md ("Command-line tools go in bin/ directory... use
standard Unix conventions"). Fix: chmod +x bin/tv_auth && git add --chmod=+x bin/tv_auth (or git update-index --chmod=+x bin/tv_auth) before
merge.
2. _jwt_exp crashes (uncaught AttributeError) on a structurally-valid
JWT whose payload segment decodes to non-object JSON, contradicting its own
contract and the "undecodable = expired" fail-safe design.
openforge/thingiverse/auth.py:52-70:
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
exp = payload.get("exp")only IndexError, ValueError, binascii.Error, json.JSONDecodeError are
caught. If the payload segment is valid JSON but not a dict (e.g. a JSON
array, string, or number — plausible for a hand-edited/corrupted token file,
or an API/format change), payload.get raises AttributeError, which is
not caught and propagates out of _token_expired() → access_token(),
crashing the whole auth path instead of the documented "can't be decoded ->
None -> treated as expired" behavior:
>>> _jwt_exp(f"{seg({'alg':'none'})}.{seg([1,2,3])}.sig")
AttributeError: 'list' object has no attribute 'get'Since the token file (~/.config/openforge/thingiverse_tokens.json) is
user-editable, untrusted, unsigned input, this fail-safe gap is a real risk,
not just theoretical. Fix: add AttributeError to the except tuple (or
isinstance(payload, dict) check before .get).
Medium Issues
3. Refresh-token rotation is neither atomic nor crash-safe, and the file
has no cross-process locking — two concerns that compound.
_store_tokens() (auth.py:265-272) opens the target file directly with
O_TRUNC and writes in place:
fd = os.open(self.token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)If the process dies (OOM-kill, Ctrl-C, laptop sleep) between the server
accepting the refresh call (which very likely invalidates/rotates the old
refresh token server-side) and this write completing, the on-disk file is
left empty or truncated, and the previous refresh token — now already
consumed — is what a subsequent run would try to reuse. The result is a full
forced re-login, and depending on timing, a JSON-corrupt file rather than a
clean rollback. There's no temp-file + os.replace() for atomicity, and no
fcntl.flock/lockfile serializing concurrent access — so two concurrent
tv_auth/future-sync-tool invocations reading the same refresh token will
race: whichever POSTs to /v2/auth/refresh second gets a 401 on an
already-rotated token and is forced into NotLoggedIn, aborting whatever
batch operation triggered it. Given this is meant to back an unattended sync
tool (per the PR's stated epic), both the non-atomic write and the missing
lock are worth addressing before other tools build on TokenManager
(write-to-temp-then-rename is a small, cheap fix; locking can be a simple
advisory flock around refresh()/_store_tokens()).
4. The 2FA cookie-binding assumption is not established anywhere in
docs/thingiverse-api-v2.md, and is untested against a live 2FA account.
login_2fa()'s docstring and the module docstring assert "the 2FA exchange
is tied to the login attempt's session cookies," but nothing in the API doc
(or the OpenAPI-derived Authenticate2FARequest {code} schema) documents
cookie semantics — the doc is silent on transport beyond the JWT Bearer
scheme. The assumption is reasonable by elimination (the 2FA endpoint's
request body carries only code, so a cookie is the only remaining channel
for the server to know which login attempt the code belongs to), but it's
inference, not spec. The PR's test plan explicitly leaves live verification
unchecked, and doesn't call out that the 2FA path specifically needs
verification against an account that actually has 2FA enabled. If the
assumption is wrong, the failure mode is an opaque ThingiverseAuthError: 2FA login failed: HTTP 401 with no hint that the real problem is "must
reuse the same TokenManager/session as the initial login() call."
Recommend: (a) add a line to docs/thingiverse-api-v2.md's auth section
noting this is an inferred/observed behavior, not from the spec, and (b)
special-case a 401/403 from login_2fa() with a clearer message.
5. logout() only deletes the local token file; it never calls the API's
own logout endpoint. docs/thingiverse-api-v2.md documents GET /v2/auth/logout ("Logout, clear login cookies"). Local-only deletion means
the refresh token is never revoked server-side — it remains valid until it
naturally expires, so "logout" doesn't actually invalidate the session,
just the local copy. That may be an intentional/acceptable scope decision for
a CLI dotfile tool, but it's worth a one-line docstring caveat so future
callers (e.g. an eventual "revoke all sessions" feature) don't assume
logout() does more than it does.
Minor Issues
auth.py:247—jwt = body.get("jwt", body)correctly distinguishes
AuthTokensResponse(hasjwtkey) fromJwtTokenResponse(returned
directly by refresh), matching the doc's two shapes. But if a response
ever included an explicit"jwt": nullor"jwt": {},.get("jwt", body)
would return that falsy value rather than falling back tobody, and
jwt.get("access")would raiseAttributeErroronNone. Low
probability given the doc's schemas, but same class of bug as #2 — an
isinstanceguard would make both spots equally robust.bin/tv_auth'sstatuscommand writes its failure states
("Not logged in.", "Tokens stored but unusable: ...") viaclick.echo(...)
to stdout, thensys.exit(1)— per the Unix-convention guidance in
openforge/CLAUDE.md("stderr for errors"), these should probably be
click.echo(..., err=True)so a non-zero exit code is paired with stderr
output, consistent with howlogin/logoutalready route failures
throughclick.ClickException(which Click prints to stderr automatically).- The refresh endpoint's
401 -> NotLoggedInspecial-case (auth.py:192)
assumes/v2/auth/refreshbehaves like an authenticated route for its
failure status code; the doc's only stated 401 semantics are for
Authorization: Bearerroutes in general, and/v2/auth/refreshtakes
the refresh token as a body param, not a Bearer header. Not necessarily
wrong, just unconfirmed by the doc — worth a quick manual check when
Devon does the live verification pass.
Positive Observations
- Login/2FA/refresh request payload key casing matches
docs/thingiverse-api-v2.mdexactly, including the (Thingiverse-side)
inconsistency betweenusernameOrEmail(camelCase, login) and
refresh_token(snake_case, refresh) — the code doesn't try to
"normalize" this away, which is the right call. _jwt_exp's base64url padding math ("=" * (-len(payload_b64) % 4)) is
correct for all four padding cases and is exercised well by the
TestJwtHelperstests.TokenManageraccepting an injectablesessionis exactly the right
seam for testing an HTTP-boundary class without a mocking framework, and
matches howSessionService/IncrementalScannerdo similar things
elsewhere in the codebase — a class here is consistent with existing
precedent for stateful resources (session + token file path), despite the
general procedural-style preference inopenforge/CLAUDE.md.- Token file permissions (
0600file,0700dir) are set correctly and
proactively viaos.open/os.chmodrather than relying on umask. ruff checkandruff format --diffare clean on all four changed
Python files;pytest tests/test_thingiverse_auth.pyis 25/25 green.
Recommendations Summary
Must fix before merge:
- Set the executable bit on
bin/tv_auth(Critical #1). - Guard
_jwt_expagainst non-dict JSON payloads so malformed/corrupt
tokens degrade to "expired" instead of crashing (Critical #2).
Should fix:
- Make
_store_tokensatomic (write-temp +os.replace) and add a simple
lock around refresh/persist to avoid the concurrent-invocation race
(Medium #3). - Document the 2FA cookie-binding assumption as inferred (not spec-backed)
indocs/thingiverse-api-v2.md, and givelogin_2fa()failures a clearer
error message (Medium #4). - Add a one-line docstring caveat that
logout()is local-only and doesn't
revoke the refresh token server-side (Medium #5).
Nice to have:
- Guard the
jwt.get(...)call similarly to #2 for the{"jwt": null}edge
case (Minor). - Route
status's failure-pathclick.echocalls to stderr (Minor). - Confirm refresh's 401 semantics during the live-verification pass (Minor).
Overall Assessment
Solid, well-tested first cut of the auth layer with good API-contract
fidelity to docs/thingiverse-api-v2.md; the two must-fix items are cheap
(a chmod and an except clause) but both are the kind of thing that bites
silently in production (a locked-out sync job, a script nobody can execute
directly), so worth closing before this becomes the foundation other
Thingiverse tickets build on.
Response to Claude ReviewCritical 1 (bin/tv_auth not executable): Fixed — exec bit set ( Critical 2 ( Medium 1 (non-atomic Medium 2 (2FA cookie-binding unverified): Acknowledged — it's an inference the public spec can't confirm; the constraint is documented on Medium 3 (logout doesn't revoke server-side): Fixed — Minor 1 ( Minor 2 (status failures on stdout): Fixed — failure messages now Minor 3 (refresh 401→NotLoggedIn mapping unconfirmed): Acknowledged — the spec documents 401 on the refresh endpoint; mapping it to |
- guard _jwt_exp against non-dict JWT payloads (AttributeError escape) - guard _handle_token_response against null/non-dict jwt in body - atomic token writes: same-dir tmp + os.replace, cleanup on failure - best-effort server-side revocation in logout() via GET /v2/auth/logout - TokenManager.close() + context-manager support for long-running reuse - extract CLI to openforge/thingiverse/cli.py (bin/tv_auth now a shim) and set its exec bit - status failure messages to stderr - drop unused leeway parameter - document THINGIVERSE_TOKEN_FILE in docs/thingiverse-api-v2.md - tests: 25 -> 52, module coverage 100% (env resolution, refresh edge cases, CLI via CliRunner incl. 2FA flow and verbosity ladder) Part of openforge_catalog-7dg Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Verification of round-1 fixes (commit c47347d)Ran 1. 2. 3. 4. 5. jwt null/non-dict guards — Confirmed: 6. Won't-fix / acknowledged rationale review:
Scan of c47347d for new issues (CLI extraction + context manager): No new issues found. Overall: LGTM. All 6 items verified fixed with passing regression tests; won't-fix/acknowledged items have sound rationale. No new issues introduced by the fix commit. Only the logout/refresh-interleaving note above is worth a mental bookmark, not a blocker. |
…finding) Part of openforge_catalog-7dg Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Live verification ✅
This closes out the two acknowledged review items: the login flow works end-to-end against the live API, and the refresh endpoint behaves as the code assumes. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
* feat: formalize general-reviewer in the agent pack This repo has no Gemini/Cursor, so the pack gains a whole-PR generalist that spawns on every PR, every round: logic correctness verified empirically, design soundness against upcoming tickets, contract fidelity, cross-cutting interactions, docs accuracy. Explicitly non-overlapping with the specialists. Previously run ad-hoc in PRs #224-#226, where this pass produced the highest-value catches (invalid ruff flag, JWT AttributeError, multi-accept AND-vs-ANY). Also documents the no-external-bots reality so future loops don't poll for Gemini. Closes openforge_catalog-aq5 (via bd close on merge) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address general-reviewer dogfood findings - annotate the skip rule (general-reviewer always matches by construction) - correct ruff-flag catch attribution to PR #223 in ticket aq5 Part of openforge_catalog-aq5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
First code PR of the Thingiverse publish/sync epic (
openforge_catalog-4kx), implementing ticketopenforge_catalog-7dg.openforge/thingiverse/auth.py—TokenManager:login()viaPOST /v2/auth/login(the SPA's own flow — no OAuth redirect needed); raisesTwoFactorRequiredon HTTP 202, completed bylogin_2fa()on the same session (the 2FA exchange is cookie-bound){access, refresh}JWTs to~/.config/openforge/thingiverse_tokens.json(0600, dir 0700, overridable viaTHINGIVERSE_TOKEN_FILE); passwords are never storedaccess_token()checks the JWTexpclaim (60s leeway, undecodable = expired) and transparently rotates viaPOST /v2/auth/refresh; rejected refresh →NotLoggedInwith a clear re-login messagewhoami()(GET /v2/users/me) to verify which account the tokens belong to — important given the v1 app token turned out to be scoped to the wrong accountbin/tv_auth— click CLI:login(prompted credentials, 2FA prompt when needed),status(verifies tokens against the live API),logout.-v/-qverbosity ladder, diagnostics to stderr.tests/test_thingiverse_auth.py— 25 tests; fake session injected at our boundary (per the pack's mocking discipline), fake JWTs with real exp claims, token-file permission assertions, password-never-in-errors assertion.docs/thingiverse-api-v2.md— v2 API reference extracted from the official OpenAPI spec (71 operations, 43 schemas), documenting the auth flows this implements and the read surface later tickets use.Test plan
pytest tests/test_thingiverse_auth.py— 25/25 greenruff check .clean; full suite run (4 pre-existingtest_incremental.pyfailures reproduce on cleantestlocally; CI is green there)bin/tv_auth login+bin/tv_auth statusto confirm a Devon-scoped JWT round-trips against the real APIBeads:
openforge_catalog-7dg(closed viabd closeon merge). Unblocksgn2(API client) together with the HAR tickethnr.🤖 Generated with Claude Code