fix(security): require OAuth state verification + refresh deps - #14
Conversation
Fix: make token_type optional in OAuth responses
Greptile SummaryThis PR enforces OAuth CSRF state verification in Confidence Score: 5/5Safe to merge — security fix is correctly implemented, prior All changes are correctness improvements (CSRF enforcement, constant-time state comparison, CVE-covering dep bumps). The previous P1 comment about the hand-rolled XOR loop has been resolved by adopting No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[App calls get_auth_url] --> B[Client generates random state\nand builds authorize URL]
B --> C[App persists state in session\nand redirects user]
C --> D[User grants access on Threads]
D --> E[Threads redirects to callback\nwith params in URI]
E --> F[App calls exchange_code_for_token\nstored_state + received_state]
F --> G{State checks}
G -->|empty stored or received| H[401 AuthenticationError\nstate missing]
G -->|states differ\nconstant-time compare| I[401 AuthenticationError\nstate mismatch]
G -->|states match| J[POST /oauth/access_token]
J --> K[Store TokenInfo in client]
K --> L[Ok]
Reviews (2): Last reviewed commit: "chore: replace hand-rolled constant_time..." | Re-trigger Greptile |
Previously `exchange_code_for_token(code)` delegated CSRF protection
entirely to the caller — a caller who forgot to compare the callback
`state` to the one persisted after `get_auth_url` had an open CSRF hole
(RFC 6749 §10.12).
The method now takes the caller-persisted `stored_state` and the
callback `received_state`, and fails closed before any network call:
* empty stored or received state → AuthenticationError (401, "state
missing"); prevents the empty-string-equals-empty-string trap.
* constant-time compare via a small private helper; avoids timing
leaks about the matching prefix length.
* on mismatch → AuthenticationError (401, "state mismatch").
Callers migrate:
- client.exchange_code_for_token("CODE").await?;
+ let (auth_url, state) = client.get_auth_url(&[]);
+ // persist `state`, redirect user, then on callback:
+ client.exchange_code_for_token("CODE", &state, received_state).await?;
README example updated. Three new unit tests cover the empty-stored,
empty-received, and mismatch cases; `constant_time_eq` has its own
basic test. `cargo fmt`, `clippy -D warnings`, and `cargo test
--all-features` (173 + 1 doctest) all pass.
Resolves the audit warnings reported by `cargo audit`:
* RUSTSEC-2026-0098 / RUSTSEC-2026-0099 — `rustls-webpki` accepted
incorrect URI name constraints / wildcard-name certificates. Fixed
transitively by pulling in rustls-webpki >= 0.103.12 via the
refreshed lockfile (also surfaced under reqwest 0.13's hyper-rustls
chain).
* RUSTSEC-2026-0097 — `rand 0.9` unsoundness with custom loggers.
Bumped to `rand = "0.10"`. The `Rng` trait was split: the
`random()` method now lives on `RngExt`, so the import in
`src/auth.rs` is updated accordingly.
reqwest 0.13 split the `form` and `query` builders behind their own
feature flags; both are now enabled alongside `json`. No other source
changes were required — `cargo build`, `cargo clippy --all-targets
--all-features -D warnings`, `cargo test --all-features`
(173 + doctest), and `cargo audit` all pass cleanly.
Code review feedback: a hand-rolled XOR-accumulation loop has no mechanism to stop LLVM from optimising it into an early-exit branch (dead-store elimination, branch folding), so it cannot guarantee the constant-time semantics the doc comment promises. Replace with `subtle::ConstantTimeEq::ct_eq`, which uses proper compiler barriers. Bump version 0.1.1 → 0.2.0: the OAuth state CSRF fix is a breaking API change to `exchange_code_for_token`, and reqwest 0.12 → 0.13 / rand 0.9 → 0.10 are major dep bumps that affect downstream callers via re-export. Pre-1.0 semver convention treats minor bumps as breaking.
8f6607f to
f07c4a7
Compare
The Go client (threads-go) received several fixes since this port last synced (Go conformance audit #24 / Rust audit PR #2). This ports the missing ones: - debug_token now calls Meta with the app access token (TH|client_id|client_secret), falling back to the user token when credentials are absent, and defaults input_token to the stored token. Meta resolves the request's app context from the caller token and rejects user tokens for dev-mode apps (Go #28). - Client::with_token falls back to /me?fields=id when debug_token fails (graph.threads.net returns HTTP 500 for valid tokens on dev-mode apps), bootstrapping TokenInfo with the user ID and a 60-day expiry (Go #29). - HTTP error classification: API error codes 190/102 map to AuthenticationError regardless of HTTP status (Meta returns 5xx for these on some endpoints), and authentication errors are never retried. Code 10 (GraphMethodException) is classified as permanent and never retried — retrying burns publish quota (Go #29, #30). - Recovery from /threads_publish false failures (Go #30/#31): when publish fails with code 10, the container may have been published anyway. The client polls the container status until PUBLISHED, then locates the post among the user's recent posts using content-based matchers (text/topic_tag/reply/quote state, children count for carousels). Matching fails closed on ambiguity or when the content has no unique discriminator, surfacing the original publish error. Not ported from Go #27 (already covered or N/A here): OAuth state CSRF (done in #14), URL log redaction (we never log query strings), the rate-limiter swap race (single shared Arc<RateLimiter> here), and the DeletePostWithConfirmation ownership fix (that API was never part of the Rust surface). Also puts the existing wiremock dev-dependency to use: new integration suite covering the debug_token caller token, the /me fallback, non-retryable auth errors, and the four recovery outcomes.
* fix: port threads-go fixes #28–#31 — auth hardening + publish recovery The Go client (threads-go) received several fixes since this port last synced (Go conformance audit #24 / Rust audit PR #2). This ports the missing ones: - debug_token now calls Meta with the app access token (TH|client_id|client_secret), falling back to the user token when credentials are absent, and defaults input_token to the stored token. Meta resolves the request's app context from the caller token and rejects user tokens for dev-mode apps (Go #28). - Client::with_token falls back to /me?fields=id when debug_token fails (graph.threads.net returns HTTP 500 for valid tokens on dev-mode apps), bootstrapping TokenInfo with the user ID and a 60-day expiry (Go #29). - HTTP error classification: API error codes 190/102 map to AuthenticationError regardless of HTTP status (Meta returns 5xx for these on some endpoints), and authentication errors are never retried. Code 10 (GraphMethodException) is classified as permanent and never retried — retrying burns publish quota (Go #29, #30). - Recovery from /threads_publish false failures (Go #30/#31): when publish fails with code 10, the container may have been published anyway. The client polls the container status until PUBLISHED, then locates the post among the user's recent posts using content-based matchers (text/topic_tag/reply/quote state, children count for carousels). Matching fails closed on ambiguity or when the content has no unique discriminator, surfacing the original publish error. Not ported from Go #27 (already covered or N/A here): OAuth state CSRF (done in #14), URL log redaction (we never log query strings), the rate-limiter swap race (single shared Arc<RateLimiter> here), and the DeletePostWithConfirmation ownership fix (that API was never part of the Rust surface). Also puts the existing wiremock dev-dependency to use: new integration suite covering the debug_token caller token, the /me fallback, non-retryable auth errors, and the four recovery outcomes. * fix: audit round — error-body parsing, forward-compat media types, cleanups Correctness fixes found by review of the ported changes: - http.rs: parse API error code/error_subcode as u64 and clamp on conversion. Meta subcodes routinely exceed u16 (e.g. 2207026), and an out-of-range integer failed the WHOLE error-body parse — silently dropping code 190/102/10 classification and re-enabling retries for auth errors and code-10 publish failures, defeating the previous commit. Also apply code/is_transient/subcode independently of the message: an error body with a code but empty message was previously misclassified as a retryable 5xx. - types: add MediaType::Unknown with #[serde(other)]. Without it, any new media_type value from Meta failed deserialization of the entire posts response — breaking every listing call and aborting publish recovery exactly when the post exists. - client.rs: with_token no longer fabricates an AuthenticationError when the /me fallback fails; the typed /me error is propagated so callers can distinguish a transient outage from an invalid token (matches Go, which wraps with %w). An empty /me ID surfaces the original debug_token error. - recovery: own the false-failure code list (is_publish_false_failure_code) instead of aliasing the retry policy's permanent-code list — tuning "don't retry this code" must not silently start triggering recovery polls for codes that genuinely failed. Cleanups: collapse the four duplicated publish/recover blocks into publish_with_recovery; route text_matcher through the shared media_content_matches; drop a dead chrono unwrap_or fallback; single extract_base_fields pass in is_retryable_error; derive Default on the four *PostContent structs (Go zero-value ergonomics) and simplify create_quote_post and test constructors with struct-update syntax. Regression tests: subcode > u16, code-with-empty-message, unknown media_type unit + end-to-end recovery with an unknown-type post in the listing window. * chore: bump to 0.3.0 for MediaType variant addition; address review notes - Bump version 0.2.0 -> 0.3.0: adding MediaType::Unknown to an exhaustive public enum is a breaking change (cargo-semver-checks enum_variant_added), consistent with the 0.1.1 -> 0.2.0 bump for the OAuth state change. - Document the /me-fallback expiry tradeoff on Client::with_token: the fallback cannot read the token's real remaining lifetime, so expires_at assumes a full 60 days; callers hitting the fallback consistently should refresh proactively. - Document that Default on the *PostContent structs yields values that fail validation (empty text/image_url/video_url/children) — required fields must always be set explicitly.
Summary
Two security-related changes ported from the threads-go fixes:
CSRF: enforce OAuth state in
exchange_code_for_token(src/auth.rs)stored_state(fromget_auth_url) +received_state(from callback)AuthenticationError(401) before any network callDependency hygiene (resolves
cargo auditfindings)form,queryfeatures split out in 0.13)Rng→RngExtforrandom())Breaking API change to
exchange_code_for_token. Callers migrate:Test plan