From 7a095682cdbd819c7b75cd266af5a9ba43e169a4 Mon Sep 17 00:00:00 2001 From: Oli Claude Date: Mon, 11 May 2026 10:34:20 +0000 Subject: [PATCH] fix: refresh endpoint uses refresh-token header, not body tryRefresh() was POSTing the refresh JWT in a JSON body field, which the backend rejects with a pydantic 422 ("missing header"). The error was swallowed by the try/catch, so callers saw the original 401 propagated through with no indication that the refresh attempt had even happened - effectively treating every access-token expiry (~12h) as a forced re-login. Fixed by sending the token as a `refresh-token` HTTP header, matching what the Flutter app does (confirmed via the reverse-engineered Dart disassembly: pp+0x9180 "refresh-token"). With this fix the session survives until the refresh token itself expires, ~30 days. Also updates CLOUD_API.md and openapi.yaml to document the actual header-based contract. Bumps version to 0.5.1. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 11 +++++++++++ CLOUD_API.md | 2 +- openapi.yaml | 18 +++++++++--------- package-lock.json | 4 ++-- package.json | 2 +- src/client.ts | 14 +++++++++++--- 6 files changed, 35 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fe91e..5736b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented here. The format roughly follows [Keep a Changelog](https://keepachangelog.com/) and the project uses [Semantic Versioning](https://semver.org/). +## [0.5.1] - 2026-05-11 + +### Fixed + +- **Auto-refresh of the access token now actually works.** The refresh endpoint reads the refresh JWT from a `refresh-token` HTTP header, not a JSON body — sending it as a body returned a pydantic 422 silently swallowed by `tryRefresh()`, which then propagated as an unauthenticated state to the caller. With this fix the CLI / library stays signed in for as long as the refresh token is valid (currently ~30 days), instead of forcing a manual `flappie login` after the ~12-hour access-token expiry. + +### Changed + +- `CLOUD_API.md` and `openapi.yaml` updated to document the refresh endpoint's actual header-based contract. + ## [0.5.0] - 2026-05-11 Docs-and-DX release: same wire surface as 0.4.0, but the package is meaningfully easier to discover and use. @@ -39,5 +49,6 @@ First public release on npm. - `RE.md` reverse-engineering playbook for when the vendor's mobile app updates. - Unit tests for pure CLI helpers (`parseBool`, `normalizePolicy`, `parseWeekdays`, `summarizeSettings`). +[0.5.1]: https://github.com/ooswald/flappie-api/releases/tag/v0.5.1 [0.5.0]: https://github.com/ooswald/flappie-api/releases/tag/v0.5.0 [0.4.0]: https://github.com/ooswald/flappie-api/releases/tag/v0.4.0 diff --git a/CLOUD_API.md b/CLOUD_API.md index aa604af..be0009e 100644 --- a/CLOUD_API.md +++ b/CLOUD_API.md @@ -55,7 +55,7 @@ Do not "fix" this — it matches what the backend actually accepts. | Method | Path | Body | CLI | |--------|------|------|-----| | POST | `/api/v1/users/login` | `{ email, password }` | ✅ `flappie login` | -| POST | `/api/v1/users/refresh` | `{ refresh_token }` | ✅ (auto on 401) | +| POST | `/api/v1/users/refresh` | (header `refresh-token: `, no body) | ✅ (auto on 401) | | POST | `/api/v1/users/validate-email` | `{ email }` | 🟡 | | POST | `/api/v1/users/reset-password` | `{ email }` | 🟡 | | POST | `/api/v1/users/reset-password/confirm-code` | `{ email, code }` | 🟡 | diff --git a/openapi.yaml b/openapi.yaml index da93a36..c3afa18 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -60,16 +60,16 @@ paths: post: tags: [auth] summary: Trade a refresh token for a new access token + description: | + The refresh token is sent as a request **header** (`refresh-token: `), + not in the JSON body. Sending it as a body field returns a pydantic 422. security: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [refresh_token] - properties: - refresh_token: { type: string } + parameters: + - in: header + name: refresh-token + required: true + schema: { type: string } + description: The refresh JWT from a prior login. responses: "200": description: New token pair diff --git a/package-lock.json b/package-lock.json index 33a130e..7c77843 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flappie-cli", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flappie-cli", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "commander": "^13.0.0" }, diff --git a/package.json b/package.json index 1aac994..e37af9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flappie-api", - "version": "0.5.0", + "version": "0.5.1", "description": "Control your Flappie cat door from Node or the terminal. Typed TypeScript client with full CRUD for time plans, settings, and prey events. Unofficial, not associated with Flappie Technologies AG.", "type": "module", "license": "MIT", diff --git a/src/client.ts b/src/client.ts index d3825b3..1666467 100644 --- a/src/client.ts +++ b/src/client.ts @@ -153,14 +153,22 @@ export class FlappieClient { return data as T; } - /** Try to refresh the access token. Returns true on success. */ + /** + * Try to refresh the access token. Returns true on success. + * + * The Flappie backend reads the refresh token from the `refresh-token` + * HTTP header (not a JSON body) - sending it in the body returns a + * pydantic 422 about a missing header field. + */ async tryRefresh(): Promise { if (!this.auth.refresh_token) return false; try { const res = await this.fetchFn(`${this.baseUrl}/api/v1/users/refresh`, { method: "POST", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify({ refresh_token: this.auth.refresh_token }), + headers: { + "Accept": "application/json", + "refresh-token": this.auth.refresh_token, + }, }); if (!res.ok) return false; const data = (await res.json()) as Partial & { accessToken?: string; refreshToken?: string };