| 284 |
284 |
|
return Err(AppError::BadRequest("code_challenge_method must be 'S256'".to_string()));
|
| 285 |
285 |
|
}
|
| 286 |
286 |
|
|
|
287 |
+ |
// An S256 challenge is base64url-nopad of a SHA-256: exactly 43 chars (44
|
|
288 |
+ |
// if a stray `=` is included). Reject anything outside that range —
|
|
289 |
+ |
// including the empty string from `?code_challenge=` — so the prompt=none
|
|
290 |
+ |
// branch below never issues a code bound to an unsatisfiable challenge
|
|
291 |
+ |
// (ultra-fuzz Run #1 Security LOW). The POST consent path enforces the same.
|
|
292 |
+ |
if !(43..=44).contains(&code_challenge.len()) {
|
|
293 |
+ |
return Err(AppError::BadRequest("code_challenge has invalid length".to_string()));
|
|
294 |
+ |
}
|
|
295 |
+ |
|
| 287 |
296 |
|
// Look up app by client_id (= sync_apps.api_key)
|
| 288 |
297 |
|
let app = db::synckit::get_sync_app_by_api_key(&state.db, client_id)
|
| 289 |
298 |
|
.await?
|
| 363 |
372 |
|
return Err(AppError::BadRequest("state parameter too long (max 1024 bytes)".to_string()));
|
| 364 |
373 |
|
}
|
| 365 |
374 |
|
// S256 challenges are exactly 43 base64url chars (no padding). Allow 44
|
| 366 |
|
- |
// for clients that include the trailing `=`. Anything wildly larger is a
|
| 367 |
|
- |
// malformed challenge that would never verify.
|
| 368 |
|
- |
if form.code_challenge.len() > 44 {
|
|
375 |
+ |
// for clients that include the trailing `=`. Reject anything outside that
|
|
376 |
+ |
// range — including the empty string — as a malformed challenge that would
|
|
377 |
+ |
// never verify (ultra-fuzz Run #1 Security LOW: empty was not rejected).
|
|
378 |
+ |
if !(43..=44).contains(&form.code_challenge.len()) {
|
| 369 |
379 |
|
return Err(AppError::BadRequest("code_challenge has invalid length".to_string()));
|
| 370 |
380 |
|
}
|
| 371 |
381 |
|
|
| 629 |
639 |
|
.as_deref()
|
| 630 |
640 |
|
.ok_or_else(|| AppError::BadRequest("code_verifier is required".to_string()))?;
|
| 631 |
641 |
|
|
| 632 |
|
- |
// Atomically consume code (must exist, not expired, not used).
|
| 633 |
|
- |
let oauth_code = db::oauth::consume_oauth_code(&state.db, code)
|
|
642 |
+ |
// Peek the code (does NOT consume it) so a failed client_id / redirect_uri
|
|
643 |
+ |
// / PKCE check leaves it usable for the legitimate client's retry instead of
|
|
644 |
+ |
// burning it (ultra-fuzz Run #1 Security LOW). The atomic consume below is
|
|
645 |
+ |
// what actually claims it, so concurrent redemptions stay race-safe.
|
|
646 |
+ |
let oauth_code = db::oauth::peek_oauth_code(&state.db, code)
|
| 634 |
647 |
|
.await?
|
| 635 |
648 |
|
.ok_or(AppError::BadRequest("Invalid or expired authorization code".to_string()))?;
|
| 636 |
649 |
|
|
| 662 |
675 |
|
return Err(AppError::BadRequest("PKCE verification failed".to_string()));
|
| 663 |
676 |
|
}
|
| 664 |
677 |
|
|
|
678 |
+ |
// All checks passed — now atomically claim the code. A None here means a
|
|
679 |
+ |
// concurrent request already redeemed it (or it expired in the gap); the
|
|
680 |
+ |
// `used_at IS NULL` guard makes double-redemption impossible.
|
|
681 |
+ |
let oauth_code = db::oauth::consume_oauth_code(&state.db, code)
|
|
682 |
+ |
.await?
|
|
683 |
+ |
.ok_or(AppError::BadRequest("Invalid or expired authorization code".to_string()))?;
|
|
684 |
+ |
|
| 665 |
685 |
|
// Re-check account liveness at redemption. A user suspended or deactivated
|
| 666 |
686 |
|
// between authorize and code->token must not receive a token; the refresh
|
| 667 |
687 |
|
// grant already applies this gate, but the code->token path skipped it,
|