From 01f0e3f688cc75333bbb1fad0c48f657f4c5e06e Mon Sep 17 00:00:00 2001 From: Devon Jones Date: Mon, 6 Jul 2026 13:16:41 -0600 Subject: [PATCH 1/3] feat: Thingiverse v2 auth/token manager (openforge_catalog-7dg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .beads/issues.jsonl | 2 +- .beads/last-touched | 2 +- bin/tv_auth | 88 +++++++++ docs/thingiverse-api-v2.md | 273 ++++++++++++++++++++++++++++ openforge/thingiverse/__init__.py | 7 + openforge/thingiverse/auth.py | 288 ++++++++++++++++++++++++++++++ tests/test_thingiverse_auth.py | 264 +++++++++++++++++++++++++++ 7 files changed, 922 insertions(+), 2 deletions(-) create mode 100644 bin/tv_auth create mode 100644 docs/thingiverse-api-v2.md create mode 100644 openforge/thingiverse/__init__.py create mode 100644 openforge/thingiverse/auth.py create mode 100644 tests/test_thingiverse_auth.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index de4ebc3..b62aa7d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -6,7 +6,7 @@ {"id":"openforge_catalog-5na","title":"Phased rollout: Test subset before bulk backfill","description":"Implement safe migration strategy: (1) Generate sprite sheets for small test set (~10-20 diverse models). (2) Deploy frontend with fallback to legacy single thumbnails. (3) Validate with users - test interactive controls, image quality, performance. (4) Iterate on camera positions/layout based on feedback. (5) Only after validation, run bulk backfill for all 8000+ STL files. Include ability to pause/resume bulk processing. Track which models have sprite sheets vs legacy thumbnails.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T10:13:57.517770238-07:00","updated_at":"2025-12-21T10:13:57.517770238-07:00","dependencies":[{"issue_id":"openforge_catalog-5na","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T10:14:18.760726353-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5na","depends_on_id":"openforge_catalog-4ji","type":"blocks","created_at":"2025-12-21T10:14:23.673079682-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5na","depends_on_id":"openforge_catalog-3cx","type":"blocks","created_at":"2025-12-21T10:14:24.071569003-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5na","depends_on_id":"openforge_catalog-dd4","type":"blocks","created_at":"2025-12-21T10:14:24.494018624-07:00","created_by":"daemon"}]} {"id":"openforge_catalog-5tg","title":"Backend API: Get thumbnail variants endpoint","description":"Create Flask endpoint to return sprite sheet info for a model. Returns: (1) Sprite sheet URL, (2) Grid layout (rows, cols, tile dimensions), (3) Angle metadata (name, position in grid, camera coords), (4) Default angle index. Frontend uses this to display correct region from sprite sheet. Handles models with legacy single thumbnail gracefully.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-21T09:58:32.287223899-07:00","updated_at":"2025-12-21T14:00:25.231595972-07:00","closed_at":"2025-12-21T14:00:25.231595972-07:00","close_reason":"Completed: Created GET /api/blueprints/\u003cblueprint_id\u003e/thumbnail-variants endpoint. Returns sprite sheet URL, grid layout (rows/cols/tile_size), angle metadata with camera positions, and default angle index. Gracefully handles legacy single thumbnails. All 319 tests passing.","dependencies":[{"issue_id":"openforge_catalog-5tg","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T09:59:18.52350897-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5tg","depends_on_id":"openforge_catalog-ubp","type":"blocks","created_at":"2025-12-21T09:59:35.26824895-07:00","created_by":"daemon"}]} {"id":"openforge_catalog-750","title":"Postgres migration: thingiverse sync-state tables","description":"New schema version adding: thingiverse_thing (blueprint_id \u003c-\u003e thing_id, status, published_at, last_synced_at) and thingiverse_file (thing ref, catalog file ref + local hash, remote file id + remote hash, last_synced_at) — the diff ledger for the sync engine. Follow existing openforge/db/schema/version_NN.py migration pattern. If the HAR ticket proves the remote hash is SHA-256 (catalog stores MD5), include storage for the second hash algorithm.","notes":"SCOPE NOTES: (1) thingiverse_file must carry a file-type/role discriminator (model/image/zip/other) since things contain mixed file types. (2) This runs against the LOCAL db; prod gets a subset via the fixture path (see openforge_catalog-8yb) — keep the mapping (blueprint_id, thing_id, public_url) cleanly separable from local-only operational state (hash ledger, sync timestamps).","status":"open","priority":2,"issue_type":"task","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:25.104764326-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T11:55:49.425508213-06:00","dependencies":[{"issue_id":"openforge_catalog-750","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:52.476138903-06:00","created_by":"Devon Jones"}]} -{"id":"openforge_catalog-7dg","title":"Auth/token manager for Thingiverse v2 (login + refresh)","description":"Python module: interactive 'login' does POST /v2/auth/login {usernameOrEmail, password} -\u003e AuthTokensResponse (handle 202 2FA via /v2/auth/2fa/login), persists refresh token securely (env file/keyring, never in repo); runtime path auto-refreshes access JWT via POST /v2/auth/refresh. Password is never stored. Buildable and testable today against the live API — verify a Devon-scoped JWT via GET /v2/users/me. Note: THINGIVERSE_APP_TOKEN in ~/.profile.d/099.api_keys.sh is v1-only and scoped to the wrong account (Joaquin Munguia, id 5577639) — do not use.","status":"open","priority":1,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:24.708129858-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T11:42:24.708129858-06:00","dependencies":[{"issue_id":"openforge_catalog-7dg","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:52.071225891-06:00","created_by":"Devon Jones"}]} +{"id":"openforge_catalog-7dg","title":"Auth/token manager for Thingiverse v2 (login + refresh)","description":"Python module: interactive 'login' does POST /v2/auth/login {usernameOrEmail, password} -\u003e AuthTokensResponse (handle 202 2FA via /v2/auth/2fa/login), persists refresh token securely (env file/keyring, never in repo); runtime path auto-refreshes access JWT via POST /v2/auth/refresh. Password is never stored. Buildable and testable today against the live API — verify a Devon-scoped JWT via GET /v2/users/me. Note: THINGIVERSE_APP_TOKEN in ~/.profile.d/099.api_keys.sh is v1-only and scoped to the wrong account (Joaquin Munguia, id 5577639) — do not use.","status":"in_progress","priority":1,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:24.708129858-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T13:11:30.872228821-06:00","dependencies":[{"issue_id":"openforge_catalog-7dg","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:52.071225891-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-8yb","title":"Propagate thingiverse sync data to AWS prod (fixture + API upsert path)","description":"The tool runs against the LOCAL Postgres, but prod (AWS Lambda + serverless Postgres) is populated by replaying fixture JSON through the Flask API (bin/upload_fixture with OPENFORGE_BASE_URL, same pattern as blueprint fixtures). Thingiverse data (blueprint_id \u003c-\u003e thing_id mapping, public thing URLs) must ride that path so the app can render Thingiverse links in prod and support deeper integration later. Deliverables: fixture format for thingiverse mappings (exported from the local sync tables), an authenticated API upsert endpoint in the Flask app, and upload_fixture support. Note: prod likely only needs the mapping/links, not the per-file hash ledger — that is local operational state; decide the split when designing the fixture.","status":"open","priority":2,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:55:33.474857151-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T11:55:33.474857151-06:00","dependencies":[{"issue_id":"openforge_catalog-8yb","depends_on_id":"openforge_catalog-750","type":"blocks","created_at":"2026-07-06T11:55:47.512062985-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-8yb","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:55:47.882392741-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-ad5","title":"Sync engine: diff local files vs remote hashes, reconcile","description":"For each managed thing: diff manifest-directed local files against remote files using hash comparison (via thingiverse_file ledger) -\u003e upload new, replace changed, delete removed, patch drifted metadata. Idempotent; dry-run mode showing planned actions before mutating. Depends on hash-algorithm verification: if remote is SHA-256, compute+store SHA-256 alongside catalog MD5s.","notes":"PRIOR ART (old v1 tool): tv_update_description did bulk find/replace of boilerplate across all things matching a name filter, with --dry-run (desc_update.json shows real mass migrations happened). tv_tag_dl mirrored all things to local JSON with 3s sleeps (self-rate-limiting; v1 had practical rate limits). Lessons: (1) metadata drift detection should cover the shared-boilerplate case so a boilerplate change re-syncs every managed thing's description; (2) throttle API calls; (3) dry-run is a first-class requirement, already in scope.","status":"open","priority":1,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:26.401028262-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T12:00:34.191543122-06:00","dependencies":[{"issue_id":"openforge_catalog-ad5","depends_on_id":"openforge_catalog-gn2","type":"blocks","created_at":"2026-07-06T11:42:43.166238543-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-ad5","depends_on_id":"openforge_catalog-750","type":"blocks","created_at":"2026-07-06T11:42:43.606420026-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-ad5","depends_on_id":"openforge_catalog-d7a","type":"blocks","created_at":"2026-07-06T11:42:44.00974978-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-ad5","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:53.860069002-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-bll","title":"Multi-Angle Thumbnail Viewing System","description":"Enable users to view STL models from 10 isometric camera angles with interactive controls. Admins can set default viewing angles per model. System generates 10 isometric-perspective angles (6 primary directions + 4 diagonal corners) during file scan using stl-thumb --cam-pos, combined into single sprite sheet PNG. Frontend has drag-to-rotate and keyboard navigation. All angles maintain 3/4 view for depth, not flat orthographic.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-12-21T09:57:50.607001989-07:00","updated_at":"2025-12-21T10:09:20.457266019-07:00"} diff --git a/.beads/last-touched b/.beads/last-touched index 4dfac3a..bc266d0 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -openforge_catalog-1v5 +openforge_catalog-7dg diff --git a/bin/tv_auth b/bin/tv_auth new file mode 100644 index 0000000..c6ab831 --- /dev/null +++ b/bin/tv_auth @@ -0,0 +1,88 @@ +#!/usr/bin/env python +"""Thingiverse v2 authentication CLI: login, status, logout. + +Logs in with your Thingiverse account (password prompted, never stored) +and persists tokens for the publish/sync tooling. See +openforge/thingiverse/auth.py. +""" + +import logging +import sys + +import click + +from openforge.thingiverse.auth import ( + NotLoggedIn, + ThingiverseAuthError, + TokenManager, + TwoFactorRequired, +) + + +def _configure_logging(verbose: int, quiet: bool): + if quiet: + level = logging.ERROR + else: + level = max(logging.WARNING - verbose * 10, logging.DEBUG) + logging.basicConfig(level=level, stream=sys.stderr) + + +@click.group() +@click.option("-v", "--verbose", count=True, help="Increase verbosity (-v, -vv)") +@click.option("-q", "--quiet", is_flag=True, help="Errors only") +def cli(verbose, quiet): + _configure_logging(verbose, quiet) + + +@cli.command() +@click.option( + "--username", + prompt="Thingiverse username or email", + help="Thingiverse username or email", +) +def login(username): + """Log in and store tokens for the sync tooling.""" + password = click.prompt("Password", hide_input=True) + manager = TokenManager() + try: + try: + manager.login(username, password) + except TwoFactorRequired: + code = click.prompt("2FA code") + manager.login_2fa(code) + user = manager.whoami() + except ThingiverseAuthError as e: + raise click.ClickException(str(e)) from e + click.echo( + f"Logged in as {user.get('name')} (id {user.get('id')}); " + f"tokens stored at {manager.token_file}" + ) + + +@cli.command() +def status(): + """Show login state and verify the stored tokens still work.""" + manager = TokenManager() + if not manager.is_logged_in(): + click.echo("Not logged in.") + sys.exit(1) + try: + user = manager.whoami() + except NotLoggedIn as e: + click.echo(f"Tokens stored but unusable: {e}") + sys.exit(1) + except ThingiverseAuthError as e: + raise click.ClickException(str(e)) from e + click.echo(f"Logged in as {user.get('name')} (id {user.get('id')})") + + +@cli.command() +def logout(): + """Delete the stored tokens.""" + manager = TokenManager() + manager.logout() + click.echo("Logged out (tokens removed).") + + +if __name__ == "__main__": + cli() diff --git a/docs/thingiverse-api-v2.md b/docs/thingiverse-api-v2.md new file mode 100644 index 0000000..1864083 --- /dev/null +++ b/docs/thingiverse-api-v2.md @@ -0,0 +1,273 @@ +# Thingiverse API v2 — Reference + +Documentation of the Thingiverse API v2, extracted from the official OpenAPI 3.0 spec +served at (Swagger UI). + +- **Spec version:** OpenAPI 3.0.0 (`Thingiverse API v2`) +- **Production base URL:** `https://api.thingiverse.com` +- **Staging base URL:** `https://api-staging.thingiverse.com` +- **Auth:** JWT Bearer token — `Authorization: Bearer ` (applied globally by default) +- **Surface:** 71 operations across 11 tags, 43 schemas + +> ⚠️ This is the **v2** API (JWT-based, the one powering the modern thingiverse.com site). +> It is distinct from the older public v1 REST API that used `?access_token=` app tokens. +> The spec exposes no documented rate-limit headers or quotas. + +--- + +## Authentication + +The default security scheme is HTTP Bearer with a JWT. Nearly every endpoint expects: + +``` +Authorization: Bearer +``` + +### Token flow + +| Concept | Where it comes from | +|---|---| +| `access` (JWT) | Short-lived; sent on every request as the Bearer token | +| `refresh` (JWT) | Long-lived; exchanged at `/v2/auth/refresh` for a new access token | +| `token` | Session/login token returned alongside JWTs by login endpoints | + +Typical shapes: + +- **`JwtTokenResponse`** — `{ access, refresh }` +- **`AuthTokensResponse`** — `{ message, token, jwt: JwtTokenResponse }` +- **`OAuthTokensResponse`** — `{ token, jwt: JwtTokenResponse }` + +A `401` response (shape `{ error }`) means the token is missing/invalid on any authenticated route. + +### Auth endpoints (`Auth`, 19 operations) + +| Method | Path | Summary | Body / Params | +|---|---|---|---| +| POST | `/v2/auth/oauth/access_token` | Exchange OAuth code for tokens | `OAuthLoginRequest` `{client_id, client_secret, code}` | +| POST | `/v2/auth/login` | Username/email + password login | `LoginRequest` `{usernameOrEmail, password}` → `200 AuthTokensResponse`, `202` = 2FA required | +| GET | `/v2/auth/view` | Get a view (read-only) token | → `JwtTokenResponse` | +| POST | `/v2/auth/refresh` | Refresh access token | `RefreshRequest` `{refresh_token}` → `JwtTokenResponse` | +| GET | `/v2/auth/logout` | Logout, clear login cookies | → `302` | +| GET | `/v2/auth/google` | Begin Google OAuth (redirects to Google) | query `redirect_uri` | +| GET | `/v2/auth/google/callback` | Handle Google OAuth callback | query `code` (req) | +| GET | `/v2/auth/token` | Get the current user's login token | — | +| POST | `/v2/auth/signup` | Create a new account | `SignUpRequest` | +| GET | `/v2/auth/{username}/verifyemail` | Send verification email | path `username` | +| POST | `/v2/auth/forgotpassword` | Send password-reset email | `ForgotPasswordRequest` `{email}` | +| POST | `/v2/auth/resetpassword` | Reset password via token | `ResetPasswordRequest` `{token, password}` | +| POST | `/v2/auth/sendcoppaform` | Send COPPA form to caretaker | `SendCoppaFormRequest` `{username, caretakerName, email}` | +| GET | `/v2/auth/recovery/generate` | Generate new 2FA recovery codes | — | +| POST | `/v2/auth/recovery/login` | Login using a recovery code | `LoginRecoveryCodesRequest` `{recoveryCode}` | +| POST | `/v2/auth/2fa/setup` | Start 2FA setup | — | +| POST | `/v2/auth/2fa/setup/complete` | Complete 2FA setup | `Authenticate2FARequest` `{code}` | +| POST | `/v2/auth/2fa/disable` | Disable 2FA | — | +| POST | `/v2/auth/2fa/login` | Complete login with a 2FA code | `Authenticate2FARequest` `{code}` → `AuthTokensResponse` | + +--- + +## Things (`Things`, 6 operations) + +A **Thing** is a published design/model. Most read endpoints come in graduated detail +levels — `minimal` < `summary` < `complete` — plus a `custom` variant that returns only +the fields you name via `?fields=`. + +| Method | Path | Returns | Notes | +|---|---|---|---| +| GET | `/v2/things/{id}` | `SingleThingResponseSummary` | Alias of `/summary` | +| GET | `/v2/things/{id}/summary` | `SingleThingResponseSummary` | | +| GET | `/v2/things/{id}/complete` | `SingleThingResponseComplete` | Full record incl. tags, license, ancestors, zip data | +| GET | `/v2/things/{id}/custom` | (filtered object) | `fields` query **required** | +| GET | `/v2/things/{id}/more-like-this` | recommendations | | +| GET | `/v2/things/{id}/secure` | — | "Secure a thing's files for download" (prep for downloading) | + +Common query params on the read routes: `image_type`, `image_size`. + +### `SingleThingResponseComplete` (key fields) + +`id`, `name`, `public_url`, `created_at`, `thumbnail`, `preview_image`, +`creator` (`SingleUserResponseMinimal`), `description_html`, `details`, `details_parts`, +`license`, `file_count`, `remix_count`, `make_count`, `like_count`, `comment_count`, +`collect_count`, `is_published`, `is_featured`, `is_edu_approved`, `is_winner`, `is_nsfw`, +`is_ai`, `is_wip`, `is_derivative`, `is_premium`, `is_liked`, `is_collected`, `is_watched`, +`allows_derivatives`, `ancestors` (`[SingleThingResponseSummary]`), `tags` (`[TagDto]`), +`zip_data` (`ZipDataDto`), `default_image` (`ImageDto`), `categories_url`, `type_name`, `rank`. + +--- + +## Files (`Files`, 2 operations) + +| Method | Path | Summary | Params / Body | +|---|---|---|---| +| GET | `/v2/files/{id}/download` | Download a thing file | path `id`; query `incrementDownload` (bool), `token` (string) → `200` file (string/binary) | +| POST | `/v2/files/image/upload/{imageType}/{id}` | Upload an image | path `imageType`, `id`; body `multipart/form-data` | + +> The `download` route pairs with `GET /v2/things/{id}/secure`, which authorizes the file set +> before downloading. `incrementDownload=true` counts the download toward stats. + +--- + +## Search (`Search`, 6 operations) + +All search endpoints are paginated (`page`, `per_page`, default `per_page=20`, `page=1`) +and accept a `term` string. Each domain adds its own filters. + +| Method | Path | Purpose | +|---|---|---| +| GET | `/v2/search/` | Global/thing search (same filters as `/search/things`) | +| GET | `/v2/search/things` | Search things (rich filter set) | +| GET | `/v2/search/users` | Search users | +| GET | `/v2/search/makes` | Search makes | +| GET | `/v2/search/collections` | Search collections | +| GET | `/v2/search/autocompletetags` | Tag autocomplete — query `term` (req), `max_suggestions` | + +### `/v2/search/things` filters (`ThingsQuery`) + +`sort`, `return`, `image_size`, `image_type`, `exclude_thing_id`, `category_id`, `user_id`, +`is_edu_approved`, `grades`, `subjects`, `standards`, `license`, `customizable`, +`show_customized`, `has_makes`, `is_featured`, `is_challenge_winner`, `liked_by`, `made_by`, +`is_derivative`, `is_wip`, `min_likes`, `posted_after`, `fields`, `show_ai`, +plus `term`, `per_page`, `page`. + +### `/v2/search/users` filters (`UsersQuery`) + +`sort`, `return`, `is_verified`, `is_featured`, `is_exclusive`, `users_user_types`, +`skill_level`, `programs`, `fields`, `term`, `per_page`, `page`. + +### Other search filters + +- **`MakesQuery`**: `sort`, `user_id`, `term`, `per_page`, `page` +- **`CollectionsQuery`**: `sort`, `user_id`, `is_featured`, `liked_by`, `term`, `per_page`, `page` + +--- + +## Users (`Users`, 16 operations) + +Users are addressable by numeric `id` or by `username`, each with the same detail ladder +(`minimal` / `summary` / `complete`) and a `custom` (`?fields=`) variant. + +| Method | Path | Returns | +|---|---|---| +| GET | `/v2/users/id/{id}` | `SingleUserResponseSummary` | +| GET | `/v2/users/id/{id}/minimal` | `SingleUserResponseMinimal` | +| GET | `/v2/users/id/{id}/summary` | `SingleUserResponseSummary` | +| GET | `/v2/users/id/{id}/complete` | `SingleUserResponseComplete` | +| GET | `/v2/users/id/{id}/custom` | filtered (`fields` req) | +| GET | `/v2/users/username/{username}` | `SingleUserResponseSummary` | +| GET | `/v2/users/username/{username}/minimal` | `SingleUserResponseMinimal` | +| GET | `/v2/users/username/{username}/summary` | `SingleUserResponseSummary` | +| GET | `/v2/users/username/{username}/complete` | `SingleUserResponseComplete` | +| GET | `/v2/users/username/{username}/custom` | filtered (`fields` req) | +| GET | `/v2/users/me` | `SingleUserResponseComplete` (the authenticated user) | +| GET | `/v2/users/{username}/validate` | Check username availability/validity | +| GET | `/v2/users/industries` | List of industries | +| PATCH | `/v2/users/{id}` | Update user data — body `EditUserRequest` | +| DELETE | `/v2/users/{id}` | Soft-delete a user | +| POST | `/v2/users/{id}/{type}` | Relationship action (e.g. follow) — path `type` | + +### `SingleUserResponseComplete` (key fields) + +Public: `id`, `name`, `first_name`, `last_name`, `public_url`, `thumbnail`, `cover`, `bio`, +`bio_html`, `location`, `country`, `website`, `twitter`, `membership_level`, `level`, +`skill_level`, `is_featured`, `is_verified`, `is_exclusive`, `is_admin`, `is_moderator`, +`accepts_tips`, `approved_vendor`, `is_following`, counts (`count_of_followers`, +`count_of_following`, `count_of_designs`, `collection_count`, `make_count`, `like_count`, +`favorite_count`), `printers`, `programs`, `types`, `groups`, `default_license`. + +Private (only on `me` / self): `email`, `2fa_enabled`, `recovery_codes_created_at`, `stripe_id`. + +`EditUserRequest` also carries email-notification toggles and password-change fields +(`current_password`, `new_password`, `confirm_new_password`). + +--- + +## Makes (`Makes`, 6 operations) + +A **Make** is a user's build of a Thing (photos + print settings). + +| Method | Path | Returns / Body | +|---|---|---| +| GET | `/v2/makes/{id}` | `SingleMakeResponseSummary` | +| GET | `/v2/makes/{id}/summary` | `SingleMakeResponseSummary` | +| GET | `/v2/makes/{id}/complete` | `SingleMakeResponseComplete` | +| POST | `/v2/makes` | Create — `PostMakeRequest` `{thing_id, description?, print_settings?}` | +| PATCH | `/v2/makes/{id}` | Update — `PatchMakeRequest` `{description?, print_settings*}` | +| DELETE | `/v2/makes/{id}` | Delete | + +Read routes accept `image_type` and `image_size`. A make embeds its `creator` +(`SingleUserResponseMinimal`) and `thing` (`SingleThingResponseSummary`). + +--- + +## Collections (`Collections`, 3 operations) + +| Method | Path | Returns | +|---|---|---| +| GET | `/v2/collections/{id}/summary` | `ThingCollectionResponseSummary` | +| GET | `/v2/collections/{id}/complete` | `ThingCollectionResponseSummary` | +| GET | `/v2/collections/{id}/things` | Paginated things (`page`, `per_page`) | + +`ThingCollectionResponseSummary`: `id`, `name`, `created_at`, `creator`, `count`, +`public_url`, `thumbnails[]`, `is_liked`, `is_featured`. + +--- + +## Challenges (`Challenges`, 9 operations) + +Design contests. Read routes have the `minimal`/`summary`/`complete` ladder; write routes +appear to be admin-only. + +| Method | Path | Returns / Body | +|---|---|---| +| GET | `/v2/challenges/{type}` | `GetChallengesRequest` `{current[], previous[]}` | +| GET | `/v2/challenges/{id}` | `SingleChallengeRequestSummary` | +| GET | `/v2/challenges/{id}/minimal` | `SingleChallengeRequestMinimal` | +| GET | `/v2/challenges/{id}/summary` | `SingleChallengeRequestSummary` | +| GET | `/v2/challenges/{id}/complete` | `SingleChallengeRequestComplete` | +| GET | `/v2/challenges/{id}/custom` | filtered (`fields` req) | +| POST | `/v2/challenges/` | Create — `PostChallengeRequest` | +| PATCH | `/v2/challenges/{id}` | Update — `PostChallengeRequest` → `GenericResponse` | +| POST | `/v2/challenges/{id}/winners` | Set winners — `[PostChallengeWinnersObject]` `{thing_id, place}` | + +--- + +## Membership, Newsletter, Webhook (misc.) + +| Tag | Method | Path | Notes | +|---|---|---|---| +| Membership | GET | `/v2/membership/signup` | query `period` (req) → `303` redirect (Stripe checkout) | +| Membership | GET | `/v2/membership/edit` | → `303` redirect (Stripe billing portal) | +| Newsletter | POST | `/v2/newsletter/subscribe` | `SubscribeRequest` `{email}` → `GenericResponse` | +| Webhook | POST | `/v2/webhook/stripe` | Stripe webhook receiver (server-to-server) | + +--- + +## Response detail levels (pattern) + +Several resources share a consistent shape convention worth relying on: + +- **`minimal`** — id, name, url, thumbnail, a few flags. Cheapest. +- **`summary`** — minimal + core counts/metadata. The default when you hit the bare `/{id}`. +- **`complete`** — everything, including HTML bodies, relationships, and heavier nested data. +- **`custom`** — you pass `?fields=a,b,c` (required) and get back only those. Use this to + minimize payload when you know exactly what you need. + +## Common response shapes + +- **Error / 401 / 404 / 400** → `{ error }` or `GenericResponse` `{ message?, error?, code? }` +- **Pagination** → `PaginationQuery` `{ page (default 1), per_page (default 20) }` + +--- + +## Notes for OpenForge integration + +- The API is JWT/session-oriented and clearly built for the first-party web app rather than + third-party app tokens — expect to manage `access`/`refresh` tokens rather than a static + API key. +- For pulling OpenForge design metadata, the useful read paths are + `GET /v2/search/things?user_id=` (or `made_by`/`liked_by`), + `GET /v2/things/{id}/complete`, and `GET /v2/files/{id}/download` + (preceded by `GET /v2/things/{id}/secure`). +- No documented rate limits in the spec; be conservative and cache, per the project's + cost-conscious/serverless posture. +- The spec lists local/staging/production servers; always target + `https://api.thingiverse.com` and make relative `/v2/...` calls in code. diff --git a/openforge/thingiverse/__init__.py b/openforge/thingiverse/__init__.py new file mode 100644 index 0000000..6c35a9b --- /dev/null +++ b/openforge/thingiverse/__init__.py @@ -0,0 +1,7 @@ +"""Thingiverse integration for the OpenForge catalog. + +Local CLI tooling (not deployed to Lambda) for publishing and syncing +catalog content to Thingiverse via the v2 JWT API. See +docs/thingiverse-api-v2.md for the API surface and beads epic +openforge_catalog-4kx for the roadmap. +""" diff --git a/openforge/thingiverse/auth.py b/openforge/thingiverse/auth.py new file mode 100644 index 0000000..2c0c4d0 --- /dev/null +++ b/openforge/thingiverse/auth.py @@ -0,0 +1,288 @@ +"""Thingiverse v2 authentication and token management. + +Handles the password login flow (including 2FA), persists the long-lived +refresh token to a local dotfile, and transparently refreshes the +short-lived access JWT for API calls. + +Token lifecycle (see docs/thingiverse-api-v2.md): +- POST /v2/auth/login {usernameOrEmail, password} -> 200 AuthTokensResponse + or 202 (2FA required, complete via POST /v2/auth/2fa/login {code}) +- POST /v2/auth/refresh {refresh_token} -> JwtTokenResponse {access, refresh} + +Passwords are never stored; only the tokens Thingiverse returns are +persisted, to a 0600 file outside the repo tree. +""" + +import base64 +import binascii +import json +import logging +import os +import time +from pathlib import Path +from typing import Dict, Optional + +import requests + +logger = logging.getLogger(__name__) + +API_BASE = "https://api.thingiverse.com" +REQUEST_TIMEOUT = 30 +USER_AGENT = "openforge-catalog-tools" + +# Refresh the access token this many seconds before its exp claim. +EXPIRY_LEEWAY = 60 + +DEFAULT_TOKEN_FILE = "~/.config/openforge/thingiverse_tokens.json" +TOKEN_FILE_ENV_VAR = "THINGIVERSE_TOKEN_FILE" + + +class ThingiverseAuthError(Exception): + """Base class for Thingiverse authentication failures.""" + + +class TwoFactorRequired(ThingiverseAuthError): + """Login accepted but a 2FA code is needed to finish (HTTP 202).""" + + +class NotLoggedIn(ThingiverseAuthError): + """No usable tokens are stored; an interactive login is required.""" + + +def _jwt_exp(token: str) -> Optional[int]: + """Extract the exp claim from a JWT without verifying the signature. + + Args: + token: Encoded JWT string + + Returns: + Unix timestamp from the exp claim, or None if the token can't be + decoded or has no exp claim + """ + try: + payload_b64 = token.split(".")[1] + # JWT segments are base64url without padding + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64)) + exp = payload.get("exp") + return int(exp) if exp is not None else None + except (IndexError, ValueError, binascii.Error, json.JSONDecodeError): + return None + + +def _token_expired(token: str, leeway: int = EXPIRY_LEEWAY) -> bool: + """Report whether a JWT is expired (or close enough to need a refresh). + + Tokens whose exp claim can't be read are treated as expired so the + caller falls through to a refresh rather than sending a dud token. + """ + exp = _jwt_exp(token) + if exp is None: + return True + return time.time() >= exp - leeway + + +class TokenManager: + """Manages Thingiverse v2 tokens: login, persistence, and refresh. + + Args: + token_file: Where tokens are persisted. Defaults to + $THINGIVERSE_TOKEN_FILE, falling back to + ~/.config/openforge/thingiverse_tokens.json + session: HTTP session to use (injectable for tests). Cookies must + persist across calls within the session because the 2FA + completion request is tied to the login attempt's cookies. + """ + + def __init__( + self, + token_file: Optional[Path] = None, + session: Optional[requests.Session] = None, + ): + if token_file is None: + token_file = Path( + os.environ.get(TOKEN_FILE_ENV_VAR) or DEFAULT_TOKEN_FILE + ).expanduser() + self.token_file = Path(token_file) + if session is None: + session = requests.Session() + session.headers["User-Agent"] = USER_AGENT + self.session = session + + # -- login flows ---------------------------------------------------- + + def login(self, username_or_email: str, password: str) -> Dict: + """Log in with username/email and password; persist the tokens. + + Args: + username_or_email: Thingiverse account identifier + password: Account password (used for this call only, not stored) + + Returns: + Stored token metadata (never the password) + + Raises: + TwoFactorRequired: If the account has 2FA enabled; complete + the login with login_2fa() on this same TokenManager + ThingiverseAuthError: On bad credentials or unexpected response + """ + resp = self.session.post( + f"{API_BASE}/v2/auth/login", + json={"usernameOrEmail": username_or_email, "password": password}, + timeout=REQUEST_TIMEOUT, + ) + if resp.status_code == 202: + logger.info("login accepted, 2FA required") + raise TwoFactorRequired( + "2FA code required to complete login (check your authenticator app)" + ) + return self._handle_token_response(resp, "login") + + def login_2fa(self, code: str) -> Dict: + """Complete a 2FA login with the authenticator code. + + Must be called on the same TokenManager that raised + TwoFactorRequired — the 2FA exchange is tied to the login + attempt's session cookies. + """ + resp = self.session.post( + f"{API_BASE}/v2/auth/2fa/login", + json={"code": code}, + timeout=REQUEST_TIMEOUT, + ) + return self._handle_token_response(resp, "2FA login") + + # -- token access --------------------------------------------------- + + def access_token(self) -> str: + """Return a valid access JWT, refreshing it if needed. + + Raises: + NotLoggedIn: If no tokens are stored or the refresh token is + rejected — an interactive login is required + """ + tokens = self._load_tokens() + access = tokens.get("access") + if access and not _token_expired(access): + return access + return self.refresh() + + def auth_header(self) -> Dict[str, str]: + """Return the Authorization header for API calls.""" + return {"Authorization": f"Bearer {self.access_token()}"} + + def refresh(self) -> str: + """Exchange the stored refresh token for a new access JWT. + + Returns: + The new access token (the rotated refresh token is persisted) + + Raises: + NotLoggedIn: If no refresh token is stored or it's rejected + """ + tokens = self._load_tokens() + refresh_token = tokens.get("refresh") + if not refresh_token: + raise NotLoggedIn("no refresh token stored; log in first") + resp = self.session.post( + f"{API_BASE}/v2/auth/refresh", + json={"refresh_token": refresh_token}, + timeout=REQUEST_TIMEOUT, + ) + if resp.status_code == 401: + raise NotLoggedIn( + "refresh token rejected (expired or revoked); log in again" + ) + stored = self._handle_token_response(resp, "token refresh") + return stored["access"] + + def whoami(self) -> Dict: + """Fetch the authenticated user via GET /v2/users/me. + + Useful to verify the stored tokens belong to the expected account. + """ + resp = self.session.get( + f"{API_BASE}/v2/users/me", + headers=self.auth_header(), + timeout=REQUEST_TIMEOUT, + ) + if resp.status_code != 200: + raise ThingiverseAuthError( + f"GET /v2/users/me returned HTTP {resp.status_code}" + ) + return resp.json() + + def logout(self): + """Delete the persisted tokens.""" + try: + self.token_file.unlink() + logger.info("removed token file") + except FileNotFoundError: + pass + + def is_logged_in(self) -> bool: + """Report whether tokens are stored (not whether they're valid).""" + return self.token_file.exists() + + # -- internals ------------------------------------------------------ + + def _handle_token_response(self, resp: requests.Response, action: str) -> Dict: + """Validate a token-bearing response and persist its tokens.""" + if resp.status_code != 200: + # Response bodies for auth errors are {"error": "..."} per the + # API doc; include the message but never echo tokens. + detail = "" + try: + detail = resp.json().get("error", "") + except (ValueError, AttributeError): + pass + raise ThingiverseAuthError( + f"{action} failed: HTTP {resp.status_code}" + + (f" ({detail})" if detail else "") + ) + body = resp.json() + # Login endpoints return AuthTokensResponse {token, jwt: {access, + # refresh}}; the refresh endpoint returns JwtTokenResponse + # {access, refresh} directly. + jwt = body.get("jwt", body) + access = jwt.get("access") + refresh = jwt.get("refresh") + if not access or not refresh: + raise ThingiverseAuthError(f"{action} succeeded but response had no tokens") + stored = { + "access": access, + "refresh": refresh, + "stored_at": int(time.time()), + } + # The session token accompanies the JWTs on login responses; keep + # it in case v1-style endpoints need it later. + if "token" in body: + stored["session_token"] = body["token"] + self._store_tokens(stored) + logger.info("%s succeeded, tokens stored", action) + return stored + + def _store_tokens(self, tokens: Dict): + """Write tokens to the token file with owner-only permissions.""" + parent = self.token_file.parent + parent.mkdir(parents=True, exist_ok=True) + os.chmod(parent, 0o700) + fd = os.open(self.token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(tokens, f, indent=2) + + def _load_tokens(self) -> Dict: + """Read stored tokens. + + Raises: + NotLoggedIn: If the token file doesn't exist or is unreadable + """ + try: + with open(self.token_file) as f: + return json.load(f) + except FileNotFoundError: + raise NotLoggedIn(f"no tokens at {self.token_file}; log in first") from None + except json.JSONDecodeError as e: + raise NotLoggedIn( + f"token file {self.token_file} is corrupt; log in again" + ) from e diff --git a/tests/test_thingiverse_auth.py b/tests/test_thingiverse_auth.py new file mode 100644 index 0000000..eaa7697 --- /dev/null +++ b/tests/test_thingiverse_auth.py @@ -0,0 +1,264 @@ +"""Tests for openforge.thingiverse.auth (TokenManager and JWT helpers).""" + +import base64 +import json +import stat +import time + +import pytest + +from openforge.thingiverse.auth import ( + API_BASE, + NotLoggedIn, + ThingiverseAuthError, + TokenManager, + TwoFactorRequired, + _jwt_exp, + _token_expired, +) + + +def make_jwt(exp=None, payload_extra=None): + """Build a structurally-valid, unsigned JWT for testing.""" + + def seg(obj): + raw = base64.urlsafe_b64encode(json.dumps(obj).encode()).decode() + return raw.rstrip("=") + + payload = dict(payload_extra or {}) + if exp is not None: + payload["exp"] = exp + return f"{seg({'alg': 'none'})}.{seg(payload)}.fakesig" + + +class FakeResponse: + def __init__(self, status_code, body=None): + self.status_code = status_code + self._body = body if body is not None else {} + + def json(self): + if isinstance(self._body, Exception): + raise self._body + return self._body + + +class FakeSession: + """Duck-typed requests.Session capturing calls, returning queued responses.""" + + def __init__(self): + self.responses = [] + self.calls = [] + self.headers = {} + + def queue(self, response): + self.responses.append(response) + return self + + def post(self, url, json=None, timeout=None, headers=None): + self.calls.append(("POST", url, json)) + return self.responses.pop(0) + + def get(self, url, timeout=None, headers=None): + self.calls.append(("GET", url, headers)) + return self.responses.pop(0) + + +@pytest.fixture +def token_file(tmp_path): + return tmp_path / "config" / "thingiverse_tokens.json" + + +@pytest.fixture +def session(): + return FakeSession() + + +@pytest.fixture +def manager(token_file, session): + return TokenManager(token_file=token_file, session=session) + + +def login_body(access, refresh, token="session-token-value"): + jwt = {"access": access, "refresh": refresh} + return {"message": "ok", "token": token, "jwt": jwt} + + +class TestJwtHelpers: + def test_jwt_exp_extracts_claim(self): + assert _jwt_exp(make_jwt(exp=1234567890)) == 1234567890 + + def test_jwt_exp_no_claim_returns_none(self): + assert _jwt_exp(make_jwt()) is None + + def test_jwt_exp_garbage_returns_none(self): + assert _jwt_exp("not-a-jwt") is None + assert _jwt_exp("") is None + assert _jwt_exp("a.!!!notbase64!!!.c") is None + + def test_token_expired_future_exp(self): + assert not _token_expired(make_jwt(exp=int(time.time()) + 3600)) + + def test_token_expired_past_exp(self): + assert _token_expired(make_jwt(exp=int(time.time()) - 10)) + + def test_token_expired_within_leeway(self): + # expires in 30s, leeway is 60s -> treat as expired + assert _token_expired(make_jwt(exp=int(time.time()) + 30)) + + def test_token_expired_undecodable_treated_expired(self): + assert _token_expired("garbage") + + +class TestLogin: + def test_login_success_stores_tokens(self, manager, session, token_file): + access = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(access, "refresh-jwt"))) + + stored = manager.login("devon", "hunter2") + + assert stored["access"] == access + assert stored["refresh"] == "refresh-jwt" + assert stored["session_token"] == "session-token-value" + on_disk = json.loads(token_file.read_text()) + assert on_disk["access"] == access + method, url, body = session.calls[0] + assert (method, url) == ("POST", f"{API_BASE}/v2/auth/login") + assert body == {"usernameOrEmail": "devon", "password": "hunter2"} + + def test_login_token_file_is_owner_only(self, manager, session, token_file): + session.queue(FakeResponse(200, login_body(make_jwt(exp=1), "r"))) + manager.login("devon", "pw") + mode = stat.S_IMODE(token_file.stat().st_mode) + assert mode == 0o600 + + def test_login_202_raises_two_factor(self, manager, session): + session.queue(FakeResponse(202, {"message": "2fa", "username": "devon"})) + with pytest.raises(TwoFactorRequired): + manager.login("devon", "pw") + + def test_login_401_raises_with_error_detail(self, manager, session): + session.queue(FakeResponse(401, {"error": "Unauthorized access."})) + with pytest.raises(ThingiverseAuthError, match="HTTP 401"): + manager.login("devon", "wrong") + + def test_login_error_message_never_contains_password(self, manager, session): + session.queue(FakeResponse(401, {"error": "Unauthorized access."})) + with pytest.raises(ThingiverseAuthError) as excinfo: + manager.login("devon", "s3cretpw") + assert "s3cretpw" not in str(excinfo.value) + + def test_login_missing_tokens_in_body_raises(self, manager, session): + session.queue(FakeResponse(200, {"message": "ok", "jwt": {}})) + with pytest.raises(ThingiverseAuthError, match="no tokens"): + manager.login("devon", "pw") + + def test_login_2fa_posts_code_and_stores(self, manager, session, token_file): + access = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(access, "refresh-jwt"))) + + stored = manager.login_2fa("123456") + + assert stored["access"] == access + method, url, body = session.calls[0] + assert (method, url) == ("POST", f"{API_BASE}/v2/auth/2fa/login") + assert body == {"code": "123456"} + assert token_file.exists() + + +class TestAccessToken: + def test_valid_stored_token_returned_without_refresh( + self, manager, session, token_file + ): + access = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(access, "refresh-jwt"))) + manager.login("devon", "pw") + session.calls.clear() + + assert manager.access_token() == access + assert session.calls == [] # no refresh round-trip + + def test_expired_token_triggers_refresh(self, manager, session): + expired = make_jwt(exp=int(time.time()) - 100) + fresh = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(expired, "refresh-1"))) + manager.login("devon", "pw") + # refresh endpoint returns JwtTokenResponse directly (no jwt wrapper) + session.queue(FakeResponse(200, {"access": fresh, "refresh": "refresh-2"})) + + assert manager.access_token() == fresh + method, url, body = session.calls[-1] + assert (method, url) == ("POST", f"{API_BASE}/v2/auth/refresh") + assert body == {"refresh_token": "refresh-1"} + + def test_refresh_rotates_stored_refresh_token(self, manager, session, token_file): + expired = make_jwt(exp=int(time.time()) - 100) + fresh = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(expired, "refresh-1"))) + manager.login("devon", "pw") + session.queue(FakeResponse(200, {"access": fresh, "refresh": "refresh-2"})) + manager.access_token() + + assert json.loads(token_file.read_text())["refresh"] == "refresh-2" + + def test_no_token_file_raises_not_logged_in(self, manager): + with pytest.raises(NotLoggedIn): + manager.access_token() + + def test_corrupt_token_file_raises_not_logged_in(self, manager, token_file): + token_file.parent.mkdir(parents=True) + token_file.write_text("{not json") + with pytest.raises(NotLoggedIn, match="corrupt"): + manager.access_token() + + def test_rejected_refresh_raises_not_logged_in(self, manager, session): + expired = make_jwt(exp=int(time.time()) - 100) + session.queue(FakeResponse(200, login_body(expired, "refresh-1"))) + manager.login("devon", "pw") + session.queue(FakeResponse(401, {"error": "invalid"})) + + with pytest.raises(NotLoggedIn, match="log in again"): + manager.access_token() + + def test_auth_header_shape(self, manager, session): + access = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(access, "r"))) + manager.login("devon", "pw") + + assert manager.auth_header() == {"Authorization": f"Bearer {access}"} + + +class TestWhoamiAndLogout: + def test_whoami_returns_user(self, manager, session): + access = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(access, "r"))) + manager.login("devon", "pw") + session.queue(FakeResponse(200, {"id": 42, "name": "devonjones"})) + + user = manager.whoami() + + assert user == {"id": 42, "name": "devonjones"} + method, url, headers = session.calls[-1] + assert (method, url) == ("GET", f"{API_BASE}/v2/users/me") + assert headers == {"Authorization": f"Bearer {access}"} + + def test_whoami_non_200_raises(self, manager, session): + access = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(access, "r"))) + manager.login("devon", "pw") + session.queue(FakeResponse(500, {})) + + with pytest.raises(ThingiverseAuthError, match="HTTP 500"): + manager.whoami() + + def test_logout_removes_file(self, manager, session, token_file): + session.queue(FakeResponse(200, login_body(make_jwt(exp=1), "r"))) + manager.login("devon", "pw") + assert manager.is_logged_in() + + manager.logout() + + assert not token_file.exists() + assert not manager.is_logged_in() + + def test_logout_without_file_is_noop(self, manager): + manager.logout() # must not raise From c47347d44e3e6b605612b0b8de0b845d3513cc8d Mon Sep 17 00:00:00 2001 From: Devon Jones Date: Mon, 6 Jul 2026 13:28:15 -0600 Subject: [PATCH 2/3] fix: address round-1 review findings on auth manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bin/tv_auth | 85 +--------------- docs/thingiverse-api-v2.md | 7 ++ openforge/thingiverse/auth.py | 64 ++++++++++-- openforge/thingiverse/cli.py | 83 ++++++++++++++++ tests/test_thingiverse_auth.py | 115 ++++++++++++++++++++- tests/test_thingiverse_cli.py | 177 +++++++++++++++++++++++++++++++++ 6 files changed, 438 insertions(+), 93 deletions(-) mode change 100644 => 100755 bin/tv_auth create mode 100644 openforge/thingiverse/cli.py create mode 100644 tests/test_thingiverse_cli.py diff --git a/bin/tv_auth b/bin/tv_auth old mode 100644 new mode 100755 index c6ab831..ac0fe1e --- a/bin/tv_auth +++ b/bin/tv_auth @@ -1,88 +1,11 @@ #!/usr/bin/env python -"""Thingiverse v2 authentication CLI: login, status, logout. +"""Thingiverse v2 authentication CLI shim. -Logs in with your Thingiverse account (password prompted, never stored) -and persists tokens for the publish/sync tooling. See -openforge/thingiverse/auth.py. +All logic lives in openforge.thingiverse.cli so it's unit-testable +via click's CliRunner. """ -import logging -import sys - -import click - -from openforge.thingiverse.auth import ( - NotLoggedIn, - ThingiverseAuthError, - TokenManager, - TwoFactorRequired, -) - - -def _configure_logging(verbose: int, quiet: bool): - if quiet: - level = logging.ERROR - else: - level = max(logging.WARNING - verbose * 10, logging.DEBUG) - logging.basicConfig(level=level, stream=sys.stderr) - - -@click.group() -@click.option("-v", "--verbose", count=True, help="Increase verbosity (-v, -vv)") -@click.option("-q", "--quiet", is_flag=True, help="Errors only") -def cli(verbose, quiet): - _configure_logging(verbose, quiet) - - -@cli.command() -@click.option( - "--username", - prompt="Thingiverse username or email", - help="Thingiverse username or email", -) -def login(username): - """Log in and store tokens for the sync tooling.""" - password = click.prompt("Password", hide_input=True) - manager = TokenManager() - try: - try: - manager.login(username, password) - except TwoFactorRequired: - code = click.prompt("2FA code") - manager.login_2fa(code) - user = manager.whoami() - except ThingiverseAuthError as e: - raise click.ClickException(str(e)) from e - click.echo( - f"Logged in as {user.get('name')} (id {user.get('id')}); " - f"tokens stored at {manager.token_file}" - ) - - -@cli.command() -def status(): - """Show login state and verify the stored tokens still work.""" - manager = TokenManager() - if not manager.is_logged_in(): - click.echo("Not logged in.") - sys.exit(1) - try: - user = manager.whoami() - except NotLoggedIn as e: - click.echo(f"Tokens stored but unusable: {e}") - sys.exit(1) - except ThingiverseAuthError as e: - raise click.ClickException(str(e)) from e - click.echo(f"Logged in as {user.get('name')} (id {user.get('id')})") - - -@cli.command() -def logout(): - """Delete the stored tokens.""" - manager = TokenManager() - manager.logout() - click.echo("Logged out (tokens removed).") - +from openforge.thingiverse.cli import cli if __name__ == "__main__": cli() diff --git a/docs/thingiverse-api-v2.md b/docs/thingiverse-api-v2.md index 1864083..ece2ab7 100644 --- a/docs/thingiverse-api-v2.md +++ b/docs/thingiverse-api-v2.md @@ -258,6 +258,13 @@ Several resources share a consistent shape convention worth relying on: --- +## OpenForge tooling environment variables + +- `THINGIVERSE_TOKEN_FILE` — where `openforge/thingiverse/auth.py` persists the + access/refresh JWTs (owner-only file permissions). Defaults to + `~/.config/openforge/thingiverse_tokens.json`. Name documented here only — + never commit token values. + ## Notes for OpenForge integration - The API is JWT/session-oriented and clearly built for the first-party web app rather than diff --git a/openforge/thingiverse/auth.py b/openforge/thingiverse/auth.py index 2c0c4d0..50194cd 100644 --- a/openforge/thingiverse/auth.py +++ b/openforge/thingiverse/auth.py @@ -15,6 +15,7 @@ import base64 import binascii +import contextlib import json import logging import os @@ -64,13 +65,15 @@ def _jwt_exp(token: str) -> Optional[int]: # JWT segments are base64url without padding payload_b64 += "=" * (-len(payload_b64) % 4) payload = json.loads(base64.urlsafe_b64decode(payload_b64)) + if not isinstance(payload, dict): + return None exp = payload.get("exp") return int(exp) if exp is not None else None except (IndexError, ValueError, binascii.Error, json.JSONDecodeError): return None -def _token_expired(token: str, leeway: int = EXPIRY_LEEWAY) -> bool: +def _token_expired(token: str) -> bool: """Report whether a JWT is expired (or close enough to need a refresh). Tokens whose exp claim can't be read are treated as expired so the @@ -79,7 +82,7 @@ def _token_expired(token: str, leeway: int = EXPIRY_LEEWAY) -> bool: exp = _jwt_exp(token) if exp is None: return True - return time.time() >= exp - leeway + return time.time() >= exp - EXPIRY_LEEWAY class TokenManager: @@ -213,7 +216,21 @@ def whoami(self) -> Dict: return resp.json() def logout(self): - """Delete the persisted tokens.""" + """Revoke the session server-side (best effort) and delete tokens. + + Server-side revocation via GET /v2/auth/logout is best-effort: an + unreachable API or already-dead tokens must not block removing the + local token file. + """ + if self.is_logged_in(): + try: + self.session.get( + f"{API_BASE}/v2/auth/logout", + headers=self.auth_header(), + timeout=REQUEST_TIMEOUT, + ) + except (ThingiverseAuthError, requests.RequestException) as e: + logger.warning("server-side logout failed: %s", e) try: self.token_file.unlink() logger.info("removed token file") @@ -224,6 +241,22 @@ def is_logged_in(self) -> bool: """Report whether tokens are stored (not whether they're valid).""" return self.token_file.exists() + def close(self): + """Release the HTTP session's connection pool. + + One-shot CLI invocations can skip this; long-running callers + (the sync engine) should use the context-manager form or call + close() when done. Reuse one TokenManager per process — don't + construct one per item in a loop. + """ + self.session.close() + + def __enter__(self) -> "TokenManager": + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + # -- internals ------------------------------------------------------ def _handle_token_response(self, resp: requests.Response, action: str) -> Dict: @@ -243,8 +276,10 @@ def _handle_token_response(self, resp: requests.Response, action: str) -> Dict: body = resp.json() # Login endpoints return AuthTokensResponse {token, jwt: {access, # refresh}}; the refresh endpoint returns JwtTokenResponse - # {access, refresh} directly. - jwt = body.get("jwt", body) + # {access, refresh} directly. `or body` also covers "jwt": null. + jwt = body.get("jwt") or body + if not isinstance(jwt, dict): + raise ThingiverseAuthError(f"{action} succeeded but response had no tokens") access = jwt.get("access") refresh = jwt.get("refresh") if not access or not refresh: @@ -263,13 +298,24 @@ def _handle_token_response(self, resp: requests.Response, action: str) -> Dict: return stored def _store_tokens(self, tokens: Dict): - """Write tokens to the token file with owner-only permissions.""" + """Write tokens atomically with owner-only permissions. + + Written to a temp file in the same directory and swapped in with + os.replace so a crash mid-write can't corrupt the stored tokens. + """ parent = self.token_file.parent parent.mkdir(parents=True, exist_ok=True) os.chmod(parent, 0o700) - fd = os.open(self.token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w") as f: - json.dump(tokens, f, indent=2) + tmp_path = self.token_file.with_name(self.token_file.name + ".tmp") + fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w") as f: + json.dump(tokens, f, indent=2) + os.replace(tmp_path, self.token_file) + except BaseException: + with contextlib.suppress(FileNotFoundError): + os.unlink(tmp_path) + raise def _load_tokens(self) -> Dict: """Read stored tokens. diff --git a/openforge/thingiverse/cli.py b/openforge/thingiverse/cli.py new file mode 100644 index 0000000..6acd3c5 --- /dev/null +++ b/openforge/thingiverse/cli.py @@ -0,0 +1,83 @@ +"""Thingiverse authentication CLI: login, status, logout. + +Invoked via bin/tv_auth. Logs in with your Thingiverse account +(password prompted, never stored) and persists tokens for the +publish/sync tooling. See openforge/thingiverse/auth.py. +""" + +import logging +import sys + +import click + +from openforge.thingiverse.auth import ( + NotLoggedIn, + ThingiverseAuthError, + TokenManager, + TwoFactorRequired, +) + + +def _configure_logging(verbose: int, quiet: bool): + if quiet: + level = logging.ERROR + else: + level = max(logging.WARNING - verbose * 10, logging.DEBUG) + logging.basicConfig(level=level, stream=sys.stderr) + + +@click.group() +@click.option("-v", "--verbose", count=True, help="Increase verbosity (-v, -vv)") +@click.option("-q", "--quiet", is_flag=True, help="Errors only") +def cli(verbose, quiet): + _configure_logging(verbose, quiet) + + +@cli.command() +@click.option( + "--username", + prompt="Thingiverse username or email", + help="Thingiverse username or email", +) +def login(username): + """Log in and store tokens for the sync tooling.""" + password = click.prompt("Password", hide_input=True) + manager = TokenManager() + try: + try: + manager.login(username, password) + except TwoFactorRequired: + code = click.prompt("2FA code") + manager.login_2fa(code) + user = manager.whoami() + except ThingiverseAuthError as e: + raise click.ClickException(str(e)) from e + click.echo( + f"Logged in as {user.get('name')} (id {user.get('id')}); " + f"tokens stored at {manager.token_file}" + ) + + +@cli.command() +def status(): + """Show login state and verify the stored tokens still work.""" + manager = TokenManager() + if not manager.is_logged_in(): + click.echo("Not logged in.", err=True) + sys.exit(1) + try: + user = manager.whoami() + except NotLoggedIn as e: + click.echo(f"Tokens stored but unusable: {e}", err=True) + sys.exit(1) + except ThingiverseAuthError as e: + raise click.ClickException(str(e)) from e + click.echo(f"Logged in as {user.get('name')} (id {user.get('id')})") + + +@cli.command() +def logout(): + """Delete the stored tokens.""" + manager = TokenManager() + manager.logout() + click.echo("Logged out (tokens removed).") diff --git a/tests/test_thingiverse_auth.py b/tests/test_thingiverse_auth.py index eaa7697..23c2f43 100644 --- a/tests/test_thingiverse_auth.py +++ b/tests/test_thingiverse_auth.py @@ -4,8 +4,10 @@ import json import stat import time +from pathlib import Path import pytest +import requests from openforge.thingiverse.auth import ( API_BASE, @@ -49,6 +51,10 @@ def __init__(self): self.responses = [] self.calls = [] self.headers = {} + self.closed = False + + def close(self): + self.closed = True def queue(self, response): self.responses.append(response) @@ -250,15 +256,118 @@ def test_whoami_non_200_raises(self, manager, session): with pytest.raises(ThingiverseAuthError, match="HTTP 500"): manager.whoami() - def test_logout_removes_file(self, manager, session, token_file): - session.queue(FakeResponse(200, login_body(make_jwt(exp=1), "r"))) + def test_logout_revokes_server_side_and_removes_file( + self, manager, session, token_file + ): + access = make_jwt(exp=int(time.time()) + 3600) + session.queue(FakeResponse(200, login_body(access, "r"))) manager.login("devon", "pw") assert manager.is_logged_in() + session.queue(FakeResponse(302)) manager.logout() assert not token_file.exists() assert not manager.is_logged_in() + method, url, headers = session.calls[-1] + assert (method, url) == ("GET", f"{API_BASE}/v2/auth/logout") + assert headers == {"Authorization": f"Bearer {access}"} + + def test_logout_removes_file_when_revocation_fails( + self, manager, session, token_file + ): + class ExplodingSession(FakeSession): + def get(self, url, timeout=None, headers=None): + raise requests.ConnectionError("api unreachable") + + exploding = ExplodingSession() + valid = make_jwt(exp=int(time.time()) + 3600) + exploding.queue(FakeResponse(200, login_body(valid, "r"))) + manager = TokenManager(token_file=token_file, session=exploding) + manager.login("devon", "pw") - def test_logout_without_file_is_noop(self, manager): manager.logout() # must not raise + + assert not token_file.exists() + + def test_logout_without_file_is_noop(self, manager, session): + manager.logout() # must not raise; no revoke call without tokens + assert session.calls == [] + + +class TestConstruction: + def test_token_file_from_env_var(self, monkeypatch, tmp_path): + env_path = tmp_path / "from-env.json" + monkeypatch.setenv("THINGIVERSE_TOKEN_FILE", str(env_path)) + manager = TokenManager(session=FakeSession()) + assert manager.token_file == env_path + + def test_token_file_default_when_env_unset(self, monkeypatch): + monkeypatch.delenv("THINGIVERSE_TOKEN_FILE", raising=False) + manager = TokenManager(session=FakeSession()) + expected = Path("~/.config/openforge/thingiverse_tokens.json").expanduser() + assert manager.token_file == expected + + def test_default_session_is_requests_session_with_user_agent(self, token_file): + manager = TokenManager(token_file=token_file) + assert isinstance(manager.session, requests.Session) + assert manager.session.headers["User-Agent"] == "openforge-catalog-tools" + manager.close() + + def test_context_manager_closes_session(self, token_file, session): + with TokenManager(token_file=token_file, session=session) as manager: + assert manager.session is session + assert session.closed + + +class TestErrorBranches: + def test_refresh_with_no_refresh_key_raises_not_logged_in( + self, manager, token_file + ): + token_file.parent.mkdir(parents=True) + token_file.write_text(json.dumps({"access": "only-access"})) + with pytest.raises(NotLoggedIn, match="no refresh token stored"): + manager.refresh() + + def test_non_json_error_body_still_raises_with_status(self, manager, session): + # error-detail extraction failing must not mask the HTTP failure + session.queue(FakeResponse(500, ValueError("not json"))) + with pytest.raises(ThingiverseAuthError, match="HTTP 500"): + manager.login("devon", "pw") + + def test_null_jwt_in_login_body_raises(self, manager, session): + session.queue(FakeResponse(200, {"message": "ok", "jwt": None})) + with pytest.raises(ThingiverseAuthError, match="no tokens"): + manager.login("devon", "pw") + + def test_non_dict_jwt_in_login_body_raises(self, manager, session): + session.queue(FakeResponse(200, {"message": "ok", "jwt": "not-a-dict"})) + with pytest.raises(ThingiverseAuthError, match="no tokens"): + manager.login("devon", "pw") + + def test_failed_store_cleans_up_tmp_and_reraises( + self, manager, session, token_file, monkeypatch + ): + session.queue(FakeResponse(200, login_body(make_jwt(exp=1), "r"))) + + def explode(src, dst): + raise OSError("disk full") + + monkeypatch.setattr("openforge.thingiverse.auth.os.replace", explode) + with pytest.raises(OSError, match="disk full"): + manager.login("devon", "pw") + assert list(token_file.parent.glob("*.tmp")) == [] + assert not token_file.exists() + + def test_non_dict_jwt_payload_treated_as_expired(self): + # a JWT whose payload decodes to a list must not crash exp parsing + seg = base64.urlsafe_b64encode(json.dumps([1, 2]).encode()).decode() + weird = f"x.{seg.rstrip('=')}.y" + assert _jwt_exp(weird) is None + assert _token_expired(weird) + + def test_store_leaves_no_tmp_file(self, manager, session, token_file): + session.queue(FakeResponse(200, login_body(make_jwt(exp=1), "r"))) + manager.login("devon", "pw") + leftovers = list(token_file.parent.glob("*.tmp")) + assert leftovers == [] diff --git a/tests/test_thingiverse_cli.py b/tests/test_thingiverse_cli.py new file mode 100644 index 0000000..fe2afd4 --- /dev/null +++ b/tests/test_thingiverse_cli.py @@ -0,0 +1,177 @@ +"""Tests for openforge.thingiverse.cli (tv_auth command surface).""" + +import logging + +import pytest +from click.testing import CliRunner + +import openforge.thingiverse.cli as cli_mod +from openforge.thingiverse.auth import ( + NotLoggedIn, + ThingiverseAuthError, + TwoFactorRequired, +) +from openforge.thingiverse.cli import _configure_logging, cli + + +class FakeManager: + """Scriptable TokenManager stand-in recording CLI-driven calls.""" + + def __init__( + self, + login_effect=None, + whoami_result=None, + whoami_effect=None, + logged_in=True, + ): + self.login_effect = login_effect + self.whoami_result = whoami_result or {"id": 42, "name": "devonjones"} + self.whoami_effect = whoami_effect + self.logged_in = logged_in + self.calls = [] + self.token_file = "/fake/tokens.json" + + def login(self, username, password): + self.calls.append(("login", username, password)) + if self.login_effect: + raise self.login_effect + + def login_2fa(self, code): + self.calls.append(("login_2fa", code)) + + def whoami(self): + self.calls.append(("whoami",)) + if self.whoami_effect: + raise self.whoami_effect + return self.whoami_result + + def is_logged_in(self): + return self.logged_in + + def logout(self): + self.calls.append(("logout",)) + + +@pytest.fixture +def runner(): + return CliRunner() + + +def install(monkeypatch, manager): + """Patch the CLI's TokenManager at point of use.""" + monkeypatch.setattr(cli_mod, "TokenManager", lambda: manager) + return manager + + +class TestConfigureLogging: + @pytest.mark.parametrize( + "verbose,quiet,expected", + [ + (0, False, logging.WARNING), + (1, False, logging.INFO), + (2, False, logging.DEBUG), + (5, False, logging.DEBUG), # clamps, never below DEBUG + (0, True, logging.ERROR), + (3, True, logging.ERROR), # quiet wins over -v + ], + ) + def test_level_ladder(self, monkeypatch, verbose, quiet, expected): + captured = {} + monkeypatch.setattr( + cli_mod.logging, + "basicConfig", + lambda **kwargs: captured.update(kwargs), + ) + _configure_logging(verbose, quiet) + assert captured["level"] == expected + + +class TestLogin: + def test_login_success(self, runner, monkeypatch): + manager = install(monkeypatch, FakeManager()) + + result = runner.invoke(cli, ["login"], input="devon@example.com\nhunter2\n") + + assert result.exit_code == 0 + assert "Logged in as devonjones (id 42)" in result.output + assert ("login", "devon@example.com", "hunter2") in manager.calls + + def test_login_2fa_flow_prompts_for_code(self, runner, monkeypatch): + manager = install( + monkeypatch, FakeManager(login_effect=TwoFactorRequired("2FA")) + ) + + result = runner.invoke(cli, ["login"], input="devon\nhunter2\n123456\n") + + assert result.exit_code == 0 + assert ("login_2fa", "123456") in manager.calls + assert "Logged in as devonjones" in result.output + + def test_login_bad_credentials_fails_cleanly(self, runner, monkeypatch): + install( + monkeypatch, + FakeManager(login_effect=ThingiverseAuthError("login failed: HTTP 401")), + ) + + result = runner.invoke(cli, ["login"], input="devon\nwrong\n") + + assert result.exit_code != 0 + assert "login failed: HTTP 401" in result.output + + def test_login_password_not_echoed(self, runner, monkeypatch): + install(monkeypatch, FakeManager()) + + result = runner.invoke(cli, ["login"], input="devon\nhunter2\n") + + assert "hunter2" not in result.output + + +class TestStatus: + def test_status_logged_in(self, runner, monkeypatch): + install(monkeypatch, FakeManager()) + + result = runner.invoke(cli, ["status"]) + + assert result.exit_code == 0 + assert "Logged in as devonjones (id 42)" in result.output + + def test_status_not_logged_in_exits_1(self, runner, monkeypatch): + install(monkeypatch, FakeManager(logged_in=False)) + + result = runner.invoke(cli, ["status"]) + + assert result.exit_code == 1 + assert "Not logged in." in result.output + + def test_status_unusable_tokens_exits_1(self, runner, monkeypatch): + install( + monkeypatch, + FakeManager(whoami_effect=NotLoggedIn("refresh token rejected")), + ) + + result = runner.invoke(cli, ["status"]) + + assert result.exit_code == 1 + assert "Tokens stored but unusable" in result.output + + def test_status_api_error_fails(self, runner, monkeypatch): + install( + monkeypatch, + FakeManager(whoami_effect=ThingiverseAuthError("HTTP 500")), + ) + + result = runner.invoke(cli, ["status"]) + + assert result.exit_code != 0 + assert "HTTP 500" in result.output + + +class TestLogout: + def test_logout(self, runner, monkeypatch): + manager = install(monkeypatch, FakeManager()) + + result = runner.invoke(cli, ["logout"]) + + assert result.exit_code == 0 + assert "Logged out" in result.output + assert ("logout",) in manager.calls From f9e6134649b7f00766ab06ca9ce32f8a606bfb06 Mon Sep 17 00:00:00 2001 From: Devon Jones Date: Mon, 6 Jul 2026 13:33:24 -0600 Subject: [PATCH 3/3] test: cover logout's ThingiverseAuthError branch (verification-round finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of openforge_catalog-7dg Co-Authored-By: Claude Fable 5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/test_thingiverse_auth.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_thingiverse_auth.py b/tests/test_thingiverse_auth.py index 23c2f43..6f0209d 100644 --- a/tests/test_thingiverse_auth.py +++ b/tests/test_thingiverse_auth.py @@ -294,6 +294,21 @@ def test_logout_without_file_is_noop(self, manager, session): manager.logout() # must not raise; no revoke call without tokens assert session.calls == [] + def test_logout_removes_file_when_tokens_unusable( + self, manager, session, token_file + ): + # expired access + no refresh key: auth_header raises NotLoggedIn + # (a ThingiverseAuthError) before any network call; logout must + # still remove the file + token_file.parent.mkdir(parents=True) + expired = make_jwt(exp=int(time.time()) - 100) + token_file.write_text(json.dumps({"access": expired})) + + manager.logout() # must not raise + + assert not token_file.exists() + assert session.calls == [] + class TestConstruction: def test_token_file_from_env_var(self, monkeypatch, tmp_path):