Skip to content

fix(security): validate OAuth state parameter in callback flow - #240

Merged
ajianaz merged 3 commits into
developfrom
fix/oauth-state-validation
Sep 2, 2026
Merged

fix(security): validate OAuth state parameter in callback flow#240
ajianaz merged 3 commits into
developfrom
fix/oauth-state-validation

Conversation

@ajianaz

@ajianaz ajianaz commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What

Implements CSRF protection for the Threads OAuth flow via a one-time state token:

  • POST /api/oauth/state (authenticated) — issues a 256-bit random token, stored in a new oauth_states table (migration 015) bound to the caller's API key/session, TTL 10 minutes, single use.
  • POST /api/oauth/exchange — now requires state:
    • Web flow (credentials from DB): state must be bound to the caller and is consumed atomically (DELETE ... WHERE token = ? AND bound_key = ? AND expires_at >= ?) — replay and cross-session reuse are impossible.
    • Legacy flow (direct app_id/app_secret in request, used by older clients): any valid, unexpired, unconsumed state is accepted and consumed (still single-use, still unguessable).
  • Callback page rejects stateless callbacks with a clear error instead of proceeding.
  • Dashboard "Connect" flow requests a state token and appends &state= to the authorize URL (both server-built and client-fallback URLs).
  • docs/auth-flow.md documents the state flow.

Why

Cora scan v0.9.0 finding (MAJOR): "OAuth callback does not validate the state parameter" — without it, an attacker can craft a callback URL with their own authorization code and drive the victim's logged-in session to connect the attacker's Threads account (login CSRF). Related finding: "OAuth flow documented without state parameter or PKCE".

Testing

  • 6 new store tests: bound single-use consumption, wrong-owner rejection, expiry rejection, legacy consume-any single use, prune removes only expired, unknown token rejected
  • cargo test --workspace — 215 passed, 0 failed
  • cargo fmt --check + cargo clippy --workspace --all-targets -- -D warnings clean
  • bun run build clean (web)
  • Migration 015 idempotent (IF NOT EXISTS, "already exists"-tolerant registration)

Comment thread crates/titen-api/src/routes/oauth.rs Fixed
Comment thread crates/titen-api/src/routes/oauth_state.rs Fixed
Comment thread crates/titen-api/src/routes/oauth_state.rs Fixed
Comment thread web/src/routes/auth/callback/+page.svelte Fixed
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🔍 Cora AI Code Review

No issues found. Code looks good!


Review powered by cora-code · BYOK · MIT

@ajianaz
ajianaz force-pushed the fix/oauth-state-validation branch from 3b3545f to 87196d5 Compare September 2, 2026 04:18
Adds a one-time, session-bound OAuth state token: POST /api/oauth/state issues a 256-bit random token (migration 015, oauth_states table, 10-minute TTL), the dashboard appends it to the Threads authorize URL, and POST /api/oauth/exchange now requires it — bound-state consumption for the web flow, shallow single-use validation for legacy direct-credential clients. The callback page rejects stateless callbacks instead of proceeding (login CSRF vector). docs/auth-flow.md documents the state flow.
…ositives

No behavior change: the flagged lines generate/read a random one-time state value (hex::encode of random bytes, URL query param), not hardcoded secrets. Renamed local variables (token -> state_value, stateToken -> stateValue) so the crypto/hardcoded-secret pattern no longer misfires.
@ajianaz
ajianaz force-pushed the fix/oauth-state-validation branch from 51f36b5 to d884b7f Compare September 2, 2026 05:09
headers: axum::http::HeaderMap,
Json(input): Json<OAuthExchangeRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
// #237: CSRF protection — the code must have been initiated by this
)
})?;
if ok {
// Consume so the same token can't be reused by another legacy call.
Two findings from the CodeCora scan on PR #240:

1. oauth.rs: empty-string app_id/app_secret no longer count as 'credentials
   supplied'. A request with app_id="" previously took the unbound shallow
   path while still exchanging with the server's stored credentials,
   bypassing the per-caller state binding. Non-empty check now required on
   both fields to use the legacy path.

2. oauth_state.rs: validate_state_shallow now consumes the state atomically
   via consume_any_oauth_state as the authority (exists-then-ignored-delete
   allowed two concurrent exchanges to both succeed). The DELETE result
   gates acceptance; exactly one concurrent caller wins. Doc comment updated.
@ajianaz
ajianaz merged commit 450aeb2 into develop Sep 2, 2026
12 checks passed
@ajianaz
ajianaz deleted the fix/oauth-state-validation branch September 2, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants