max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
22 files changed,
+2050 insertions,
-289 deletions
| @@ -111,6 +111,7 @@ | |||
| 111 | 111 | | `display_name` | `Option<String>` | Login success | | |
| 112 | 112 | | `oauth_state` | `String` | Login initiated (cleared after callback) | | |
| 113 | 113 | | `pkce_verifier` | `String` | Login initiated (cleared after callback) | | |
| 114 | + | | `mnw_refresh_token` | `String` | Login success; rotated on each `/auth/refresh` | | |
| 114 | 115 | | `csrf_token` | `String` | First state-changing request or template render | | |
| 115 | 116 | ||
| 116 | 117 | ## Auth Extractors | |
| @@ -135,9 +136,35 @@ | |||
| 135 | 136 | ||
| 136 | 137 | ## Token Handling | |
| 137 | 138 | ||
| 138 | - | - Access tokens are **not stored** in the session or database. They are used once during the callback to fetch userinfo, then discarded. | |
| 139 | - | - No refresh token flow. When the session expires, the user must re-authenticate through MNW. | |
| 140 | - | - The PKCE verifier is ephemeral -- generated at login initiation, consumed at callback, never persisted beyond the session. | |
| 139 | + | MT is the reference relying party for MNW's OAuth provider, dogfooding both the | |
| 140 | + | back-channel refresh-token flow (default) and the front-channel `prompt=none` | |
| 141 | + | silent re-auth. The governing principle: **the MT session governs login | |
| 142 | + | longevity; the MNW token governs only perk freshness.** Removing the stored | |
| 143 | + | token therefore never forces a re-login. | |
| 144 | + | ||
| 145 | + | - **Access tokens are never stored.** Login requests `scope=profile:read | |
| 146 | + | perks:read offline_access`; MNW returns a short-lived (≈5 min) *userinfo-scoped* | |
| 147 | + | access token plus a rotating refresh token. The access token is used once at | |
| 148 | + | callback to fetch userinfo, then discarded. It carries a userinfo audience, so | |
| 149 | + | even if captured it cannot act on MNW's sync API (closes finding S13). | |
| 150 | + | - **Refresh tokens are stored and rotated.** Only the scoped refresh token is | |
| 151 | + | persisted (`mnw_refresh_token`). `POST /auth/refresh` trades it via | |
| 152 | + | `grant_type=refresh_token` for a fresh access token **and a new refresh token** | |
| 153 | + | (rotation — the prior token is invalidated; reuse is theft-detectable on the | |
| 154 | + | MNW side). The new refresh token replaces the stored one. | |
| 155 | + | - **A dead refresh token does not log the user out.** If refresh returns | |
| 156 | + | `invalid_grant` (expired/rotated/revoked), MT clears the stored token and | |
| 157 | + | returns `401` from `/auth/refresh` **without** flushing the session. The user | |
| 158 | + | stays logged in with last-known perks and can re-link their MNW account; the | |
| 159 | + | session's own 7-day inactivity expiry governs logout. | |
| 160 | + | - **`prompt=none` silent re-auth (`GET /auth/reverify`)** is the | |
| 161 | + | zero-credential-at-rest alternative third-party RPs can choose: MT bounces the | |
| 162 | + | browser through `/oauth/authorize?prompt=none` (no `offline_access`, so no | |
| 163 | + | refresh token is issued), uses the returned short-lived token for one userinfo | |
| 164 | + | fetch, and stores nothing. If the MNW session has lapsed, MNW redirects back | |
| 165 | + | with `error=login_required` and MT keeps the last-known perks. | |
| 166 | + | - The PKCE verifier is ephemeral -- generated at login initiation, consumed at | |
| 167 | + | callback, never persisted beyond the session. | |
| 141 | 168 | ||
| 142 | 169 | ## CSRF Protection | |
| 143 | 170 |
| @@ -88,7 +88,11 @@ | |||
| 88 | 88 | const SESSION_USERNAME: &str = "username"; | |
| 89 | 89 | const SESSION_DISPLAY_NAME: &str = "display_name"; | |
| 90 | 90 | const SESSION_PERKS: &str = "perks"; | |
| 91 | - | const SESSION_ACCESS_TOKEN: &str = "mnw_access_token"; | |
| 91 | + | /// The MNW **refresh** token — scoped (`perks:read`/`profile:read`), rotating, | |
| 92 | + | /// and unable to act as the user on the sync API. This is the only MNW | |
| 93 | + | /// credential stored at rest (closing finding S13); the short-lived access | |
| 94 | + | /// token is used transiently for one userinfo fetch and never persisted. | |
| 95 | + | const SESSION_REFRESH_TOKEN: &str = "mnw_refresh_token"; | |
| 92 | 96 | const SESSION_OAUTH_STATE: &str = "oauth_state"; | |
| 93 | 97 | const SESSION_PKCE_VERIFIER: &str = "pkce_verifier"; | |
| 94 | 98 | ||
| @@ -193,13 +197,27 @@ | |||
| 193 | 197 | ||
| 194 | 198 | #[derive(Deserialize)] | |
| 195 | 199 | pub struct CallbackQuery { | |
| 196 | - | pub code: String, | |
| 200 | + | /// Absent on a `prompt=none` failure, where the provider returns `error`. | |
| 201 | + | #[serde(default)] | |
| 202 | + | pub code: Option<String>, | |
| 197 | 203 | pub state: String, | |
| 204 | + | /// OIDC error (e.g. `login_required`) from a `prompt=none` silent attempt. | |
| 205 | + | #[serde(default)] | |
| 206 | + | pub error: Option<String>, | |
| 198 | 207 | } | |
| 199 | 208 | ||
| 200 | 209 | #[derive(Deserialize)] | |
| 201 | 210 | struct TokenResponse { | |
| 202 | 211 | access_token: String, | |
| 212 | + | /// Present when the grant included `offline_access`. `Option` so a provider | |
| 213 | + | /// that declines it (or hasn't shipped refresh tokens) still parses. | |
| 214 | + | #[serde(default)] | |
| 215 | + | refresh_token: Option<String>, | |
| 216 | + | /// Informational only — login longevity is governed by the MT session, not | |
| 217 | + | /// the access token. | |
| 218 | + | #[serde(default)] | |
| 219 | + | #[allow(dead_code)] | |
| 220 | + | expires_in: Option<i64>, | |
| 203 | 221 | } | |
| 204 | 222 | ||
| 205 | 223 | #[derive(Deserialize)] | |
| @@ -217,6 +235,11 @@ | |||
| 217 | 235 | Unauthorized, | |
| 218 | 236 | Transport, | |
| 219 | 237 | BadResponse, | |
| 238 | + | /// No usable refresh token: none stored, or the stored one was expired / | |
| 239 | + | /// rotated / revoked (`invalid_grant`). The MT session is NOT torn down — | |
| 240 | + | /// the user stays logged in with last-known perks and can re-link MNW. This | |
| 241 | + | /// is distinct from `Unauthorized`, which historically flushed the session. | |
| 242 | + | RefreshUnavailable, | |
| 220 | 243 | } | |
| 221 | 244 | ||
| 222 | 245 | /// Single-attempt userinfo fetch against MNW. Callers decide retry policy. | |
| @@ -260,59 +283,114 @@ | |||
| 260 | 283 | }) | |
| 261 | 284 | } | |
| 262 | 285 | ||
| 263 | - | /// Refresh the cached perks for the current session by re-hitting MNW. | |
| 286 | + | /// Exchange the stored refresh token for a fresh short-lived access token (and a | |
| 287 | + | /// rotated refresh token). `RefreshUnavailable` distinguishes a dead refresh | |
| 288 | + | /// token (`invalid_grant` / other 4xx) from transport failure. | |
| 289 | + | async fn exchange_refresh_token( | |
| 290 | + | state: &AppState, | |
| 291 | + | refresh_token: &str, | |
| 292 | + | ) -> Result<TokenResponse, UserinfoError> { | |
| 293 | + | let url = format!("{}/oauth/token", state.config.mnw_base_url); | |
| 294 | + | let res = state | |
| 295 | + | .http | |
| 296 | + | .post(&url) | |
| 297 | + | .json(&serde_json::json!({ | |
| 298 | + | "grant_type": "refresh_token", | |
| 299 | + | "refresh_token": refresh_token, | |
| 300 | + | "client_id": state.config.oauth_client_id, | |
| 301 | + | })) | |
| 302 | + | .send() | |
| 303 | + | .await | |
| 304 | + | .map_err(|e| { | |
| 305 | + | tracing::warn!(error = %e, "refresh token transport error"); | |
| 306 | + | UserinfoError::Transport | |
| 307 | + | })?; | |
| 308 | + | ||
| 309 | + | let status = res.status(); | |
| 310 | + | if status.is_success() { | |
| 311 | + | return res.json::<TokenResponse>().await.map_err(|e| { | |
| 312 | + | tracing::warn!(error = %e, "refresh token response parse failed"); | |
| 313 | + | UserinfoError::BadResponse | |
| 314 | + | }); | |
| 315 | + | } | |
| 316 | + | if status.is_server_error() { | |
| 317 | + | return Err(UserinfoError::Transport); | |
| 318 | + | } | |
| 319 | + | // 4xx — invalid_grant (expired/rotated/revoked) or bad request. | |
| 320 | + | let body = res.text().await.unwrap_or_default(); | |
| 321 | + | tracing::warn!(%status, %body, "refresh token exchange rejected"); | |
| 322 | + | Err(UserinfoError::RefreshUnavailable) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// Write a fresh userinfo snapshot into the session and mirror perks to the | |
| 326 | + | /// local users table. | |
| 327 | + | async fn apply_userinfo(state: &AppState, session: &Session, info: &UserinfoResponse) { | |
| 328 | + | if let Err(e) = session.insert(SESSION_PERKS, &info.perks).await { | |
| 329 | + | tracing::error!(error = %e, "failed to save refreshed perks"); | |
| 330 | + | } | |
| 331 | + | if let Err(e) = session.insert(SESSION_USERNAME, &info.username).await { | |
| 332 | + | tracing::error!(error = %e, "failed to save refreshed username"); | |
| 333 | + | } | |
| 334 | + | if let Err(e) = session.insert(SESSION_DISPLAY_NAME, &info.display_name).await { | |
| 335 | + | tracing::error!(error = %e, "failed to save refreshed display_name"); | |
| 336 | + | } | |
| 337 | + | // Mirror perks into users table so post rendering sees the change without | |
| 338 | + | // consulting MNW per-post. Best-effort: rendering tolerates a stale row. | |
| 339 | + | if let Err(e) = sqlx::query( | |
| 340 | + | "UPDATE users SET is_fan_plus = $2, is_creator = $3 WHERE mnw_account_id = $1", | |
| 341 | + | ) | |
| 342 | + | .bind(info.user_id) | |
| 343 | + | .bind(info.perks.fan_plus) | |
| 344 | + | .bind(info.perks.is_creator) | |
| 345 | + | .execute(&state.db) | |
| 346 | + | .await | |
| 347 | + | { | |
| 348 | + | tracing::warn!(error = %e, "failed to mirror refreshed perks to users table"); | |
| 349 | + | } | |
| 350 | + | let _ = info.avatar_url; // not stored in session yet | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /// Refresh the cached perks for the current session via the MNW refresh token. | |
| 264 | 354 | /// | |
| 265 | - | /// Caller must have a logged-in session (access token stored at login). On | |
| 266 | - | /// `Unauthorized` the session is flushed — the access token is gone for good | |
| 267 | - | /// and the user needs to log in again. Other errors leave the session intact. | |
| 355 | + | /// Trades the stored (scoped, rotating) refresh token for a short-lived access | |
| 356 | + | /// token, persists the rotated refresh token, fetches userinfo with the access | |
| 357 | + | /// token, and updates cached perks. **Never tears down the MT session**: login | |
| 358 | + | /// longevity is governed by the session itself, so a dead refresh token yields | |
| 359 | + | /// `RefreshUnavailable` (and clears the stored token) rather than logging the | |
| 360 | + | /// user out — they keep last-known perks and can re-link their MNW account. | |
| 268 | 361 | pub async fn refresh_session( | |
| 269 | 362 | state: &AppState, | |
| 270 | 363 | session: &Session, | |
| 271 | 364 | ) -> Result<UserPerks, UserinfoError> { | |
| 272 | - | let token: String = session | |
| 273 | - | .get(SESSION_ACCESS_TOKEN) | |
| 365 | + | let refresh_token: String = session | |
| 366 | + | .get(SESSION_REFRESH_TOKEN) | |
| 274 | 367 | .await | |
| 275 | 368 | .unwrap_or(None) | |
| 276 | - | .ok_or(UserinfoError::Unauthorized)?; | |
| 369 | + | .ok_or(UserinfoError::RefreshUnavailable)?; | |
| 277 | 370 | ||
| 278 | - | match fetch_userinfo(&state.http, &state.config.mnw_base_url, &token).await { | |
| 279 | - | Ok(info) => { | |
| 280 | - | if let Err(e) = session.insert(SESSION_PERKS, &info.perks).await { | |
| 281 | - | tracing::error!(error = %e, "failed to save refreshed perks"); | |
| 371 | + | let token = match exchange_refresh_token(state, &refresh_token).await { | |
| 372 | + | Ok(t) => t, | |
| 373 | + | Err(UserinfoError::RefreshUnavailable) => { | |
| 374 | + | // Dead refresh token — drop it, but keep the user logged in. | |
| 375 | + | if let Err(e) = session.remove::<String>(SESSION_REFRESH_TOKEN).await { | |
| 376 | + | tracing::warn!(error = %e, "failed to remove dead refresh token"); | |
| 282 | 377 | } | |
| 283 | - | // Username/display can drift on MNW too — sync them while we're here. | |
| 284 | - | if let Err(e) = session.insert(SESSION_USERNAME, &info.username).await { | |
| 285 | - | tracing::error!(error = %e, "failed to save refreshed username"); | |
| 286 | - | } | |
| 287 | - | if let Err(e) = session.insert(SESSION_DISPLAY_NAME, &info.display_name).await { | |
| 288 | - | tracing::error!(error = %e, "failed to save refreshed display_name"); | |
| 289 | - | } | |
| 290 | - | // Mirror perks into users table so post rendering sees the change | |
| 291 | - | // without consulting MNW per-post. Best-effort: rendering tolerates | |
| 292 | - | // a stale row, so DB errors here are logged but non-fatal. | |
| 293 | - | if let Err(e) = sqlx::query( | |
| 294 | - | "UPDATE users SET is_fan_plus = $2, is_creator = $3 WHERE mnw_account_id = $1", | |
| 295 | - | ) | |
| 296 | - | .bind(info.user_id) | |
| 297 | - | .bind(info.perks.fan_plus) | |
| 298 | - | .bind(info.perks.is_creator) | |
| 299 | - | .execute(&state.db) | |
| 300 | - | .await | |
| 301 | - | { | |
| 302 | - | tracing::warn!(error = %e, "failed to mirror refreshed perks to users table"); | |
| 303 | - | } | |
| 304 | - | let _ = info.avatar_url; // not stored in session yet | |
| 305 | - | Ok(info.perks) | |
| 378 | + | return Err(UserinfoError::RefreshUnavailable); | |
| 306 | 379 | } | |
| 307 | - | Err(UserinfoError::Unauthorized) => { | |
| 308 | - | // Token revoked, expired, or user deleted — drop the session. | |
| 309 | - | if let Err(e) = session.flush().await { | |
| 310 | - | tracing::warn!(error = %e, "failed to flush session after auth failure"); | |
| 311 | - | } | |
| 312 | - | Err(UserinfoError::Unauthorized) | |
| 313 | - | } | |
| 314 | - | Err(e) => Err(e), | |
| 380 | + | Err(e) => return Err(e), | |
| 381 | + | }; | |
| 382 | + | ||
| 383 | + | // Rotation: persist the new refresh token, invalidating the one just used. | |
| 384 | + | if let Some(new_rt) = token.refresh_token.as_deref() | |
| 385 | + | && let Err(e) = session.insert(SESSION_REFRESH_TOKEN, new_rt).await | |
| 386 | + | { | |
| 387 | + | tracing::error!(error = %e, "failed to persist rotated refresh token"); | |
| 315 | 388 | } | |
| 389 | + | ||
| 390 | + | // The short-lived access token is used here and then discarded — never stored. | |
| 391 | + | let info = fetch_userinfo(&state.http, &state.config.mnw_base_url, &token.access_token).await?; | |
| 392 | + | apply_userinfo(state, session, &info).await; | |
| 393 | + | Ok(info.perks) | |
| 316 | 394 | } | |
| 317 | 395 | ||
| 318 | 396 | // ── Handlers ── | |
| @@ -334,13 +412,54 @@ | |||
| 334 | 412 | tracing::error!(error = %e, "failed to save OAuth state to session"); | |
| 335 | 413 | } | |
| 336 | 414 | ||
| 415 | + | // Request scoped userinfo access plus offline_access so MNW issues a | |
| 416 | + | // rotating refresh token — MT then holds no long-lived, sync-capable token. | |
| 337 | 417 | let url = format!( | |
| 338 | - | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", | |
| 418 | + | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}", | |
| 339 | 419 | state.config.mnw_base_url, | |
| 340 | 420 | urlencoding::encode(&state.config.oauth_client_id), | |
| 341 | 421 | urlencoding::encode(&state.config.oauth_redirect_uri), | |
| 342 | 422 | urlencoding::encode(&oauth_state), | |
| 343 | 423 | urlencoding::encode(&challenge), | |
| 424 | + | urlencoding::encode("profile:read perks:read offline_access"), | |
| 425 | + | ); | |
| 426 | + | ||
| 427 | + | Redirect::to(&url) | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | /// `GET /auth/reverify` — silent perk re-check via OIDC `prompt=none`. | |
| 431 | + | /// | |
| 432 | + | /// The zero-credential-at-rest alternative to the back-channel refresh token: | |
| 433 | + | /// MT bounces the browser through MNW's `/oauth/authorize?prompt=none` (no | |
| 434 | + | /// `offline_access`, so no refresh token is issued) and the callback uses the | |
| 435 | + | /// returned short-lived token for one userinfo fetch, storing nothing. If the | |
| 436 | + | /// MNW session has lapsed, MNW redirects back with `error=login_required` and | |
| 437 | + | /// the callback simply keeps the user's last-known perks. MT dogfoods both this | |
| 438 | + | /// and the refresh-token flow as the reference relying-party integration. | |
| 439 | + | #[tracing::instrument(skip_all)] | |
| 440 | + | pub async fn reverify( | |
| 441 | + | State(state): State<AppState>, | |
| 442 | + | session: Session, | |
| 443 | + | ) -> impl IntoResponse { | |
| 444 | + | let verifier = generate_verifier(); | |
| 445 | + | let challenge = pkce_challenge(&verifier); | |
| 446 | + | let oauth_state = generate_state_nonce(); | |
| 447 | + | ||
| 448 | + | if let Err(e) = session.insert(SESSION_PKCE_VERIFIER, &verifier).await { | |
| 449 | + | tracing::error!(error = %e, "failed to save PKCE verifier to session"); | |
| 450 | + | } | |
| 451 | + | if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await { | |
| 452 | + | tracing::error!(error = %e, "failed to save OAuth state to session"); | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | let url = format!( | |
| 456 | + | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none", | |
| 457 | + | state.config.mnw_base_url, | |
| 458 | + | urlencoding::encode(&state.config.oauth_client_id), | |
| 459 | + | urlencoding::encode(&state.config.oauth_redirect_uri), | |
| 460 | + | urlencoding::encode(&oauth_state), | |
| 461 | + | urlencoding::encode(&challenge), | |
| 462 | + | urlencoding::encode("profile:read perks:read"), | |
| 344 | 463 | ); | |
| 345 | 464 | ||
| 346 | 465 | Redirect::to(&url) | |
| @@ -373,6 +492,22 @@ | |||
| 373 | 492 | return Redirect::to("/?error=state_mismatch"); | |
| 374 | 493 | } | |
| 375 | 494 | ||
| 495 | + | // A `prompt=none` silent attempt that couldn't proceed returns an error and | |
| 496 | + | // no code (e.g. the MNW session lapsed). Keep the user logged in with their | |
| 497 | + | // last-known perks; this is a non-event, not a login failure. | |
| 498 | + | if let Some(err) = params.error.as_deref() { | |
| 499 | + | tracing::info!(error = %err, "silent re-auth returned without a code"); | |
| 500 | + | return Redirect::to("/"); | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | let code = match params.code.as_deref() { | |
| 504 | + | Some(c) => c.to_string(), | |
| 505 | + | None => { | |
| 506 | + | tracing::warn!("callback missing both code and error"); | |
| 507 | + | return Redirect::to("/?error=missing_code"); | |
| 508 | + | } | |
| 509 | + | }; | |
| 510 | + | ||
| 376 | 511 | let verifier: String = match stored_verifier { | |
| 377 | 512 | Some(v) => v, | |
| 378 | 513 | None => { | |
| @@ -395,7 +530,7 @@ | |||
| 395 | 530 | .post(&token_url) | |
| 396 | 531 | .json(&serde_json::json!({ | |
| 397 | 532 | "grant_type": "authorization_code", | |
| 398 | - | "code": params.code, | |
| 533 | + | "code": code, | |
| 399 | 534 | "redirect_uri": state.config.oauth_redirect_uri, | |
| 400 | 535 | "code_verifier": verifier, | |
| 401 | 536 | "client_id": state.config.oauth_client_id, | |
| @@ -469,7 +604,9 @@ | |||
| 469 | 604 | tracing::error!("userinfo unauthorized — token rejected"); | |
| 470 | 605 | return Redirect::to("/?error=userinfo_fetch_failed"); | |
| 471 | 606 | } | |
| 472 | - | Err(UserinfoError::BadResponse) => { | |
| 607 | + | Err(UserinfoError::BadResponse | UserinfoError::RefreshUnavailable) => { | |
| 608 | + | // RefreshUnavailable is unreachable from fetch_userinfo (it's a | |
| 609 | + | // refresh-grant outcome), but the match must be exhaustive. | |
| 473 | 610 | tracing::error!("userinfo bad response"); | |
| 474 | 611 | return Redirect::to("/?error=userinfo_parse_failed"); | |
| 475 | 612 | } | |
| @@ -532,12 +669,17 @@ | |||
| 532 | 669 | perks: info.perks, | |
| 533 | 670 | }; | |
| 534 | 671 | session_user.save_to_session(&session).await; | |
| 535 | - | // Stash the access token so `refresh_session` can re-hit userinfo without | |
| 536 | - | // forcing the user through another OAuth round trip. Token lifetime is set | |
| 537 | - | // by MNW (7d as of writing); after expiry, refresh returns Unauthorized and | |
| 538 | - | // the session is flushed. | |
| 539 | - | if let Err(e) = session.insert(SESSION_ACCESS_TOKEN, &token.access_token).await { | |
| 540 | - | tracing::error!(error = %e, "failed to save access token to session"); | |
| 672 | + | // Store the rotating refresh token (NOT the access token) so future perk | |
| 673 | + | // refreshes can mint short-lived access tokens without another OAuth round | |
| 674 | + | // trip. The access token was already used for the userinfo fetch above and | |
| 675 | + | // is now discarded. A provider that declined offline_access returns no | |
| 676 | + | // refresh token; then perk-refresh is simply unavailable until re-login. | |
| 677 | + | if let Some(refresh_token) = token.refresh_token.as_deref() { | |
| 678 | + | if let Err(e) = session.insert(SESSION_REFRESH_TOKEN, refresh_token).await { | |
| 679 | + | tracing::error!(error = %e, "failed to save refresh token to session"); | |
| 680 | + | } | |
| 681 | + | } else { | |
| 682 | + | tracing::warn!("token response carried no refresh token; perk refresh disabled this session"); | |
| 541 | 683 | } | |
| 542 | 684 | if let Err(e) = session.cycle_id().await { | |
| 543 | 685 | tracing::warn!(error = %e, "Failed to cycle session ID"); | |
| @@ -560,7 +702,11 @@ | |||
| 560 | 702 | ) -> Result<Json<RefreshResponse>, StatusCode> { | |
| 561 | 703 | match refresh_session(&state, &session).await { | |
| 562 | 704 | Ok(perks) => Ok(Json(RefreshResponse { perks })), | |
| 563 | - | Err(UserinfoError::Unauthorized) => Err(StatusCode::UNAUTHORIZED), | |
| 705 | + | // 401 means "perks couldn't be refreshed" — NOT logged out. The session | |
| 706 | + | // is intact; the frontend can surface a re-link affordance. No flush. | |
| 707 | + | Err(UserinfoError::Unauthorized) | Err(UserinfoError::RefreshUnavailable) => { | |
| 708 | + | Err(StatusCode::UNAUTHORIZED) | |
| 709 | + | } | |
| 564 | 710 | Err(UserinfoError::Transport) => Err(StatusCode::BAD_GATEWAY), | |
| 565 | 711 | Err(UserinfoError::BadResponse) => Err(StatusCode::BAD_GATEWAY), | |
| 566 | 712 | } |
| @@ -1,8 +1,22 @@ | |||
| 1 | 1 | //! HMAC-SHA256 authentication for internal API requests from MNW. | |
| 2 | 2 | //! | |
| 3 | - | //! MNW signs requests with `HMAC-SHA256(timestamp + "\n" + body, secret)`. | |
| 4 | - | //! The signature and timestamp are sent in `X-Internal-Signature` and | |
| 5 | - | //! `X-Internal-Timestamp` headers. Requests older than 60 seconds are rejected. | |
| 3 | + | //! The v2 signed message binds method + path + nonce as well as timestamp + | |
| 4 | + | //! body — `HMAC-SHA256(timestamp \n METHOD \n PATH \n NONCE \n body)` — sent in | |
| 5 | + | //! `X-Internal-{Timestamp,Signature,Nonce}`. Binding method+path stops a | |
| 6 | + | //! captured signature being replayed to a different endpoint; the nonce, checked | |
| 7 | + | //! against a single-use cache, stops it being re-sent at all within the 60s | |
| 8 | + | //! freshness window. | |
| 9 | + | //! | |
| 10 | + | //! **Lockstep rollout:** this verifier is in the dual-accept transition state — | |
| 11 | + | //! a request with no `X-Internal-Nonce` is verified against the legacy v1 | |
| 12 | + | //! message (timestamp+body) for compatibility with a not-yet-upgraded MNW | |
| 13 | + | //! signer. Once the server signer is fully deployed, TIGHTEN: require a nonce | |
| 14 | + | //! and delete the v1 fallback (`verify_internal_signature` + the `None` branch | |
| 15 | + | //! of `verify_signed_request`). Until then there is a brief window where a v1 | |
| 16 | + | //! signature is replayable — keep the dual-accept period short. | |
| 17 | + | ||
| 18 | + | use std::collections::HashMap; | |
| 19 | + | use std::sync::{LazyLock, Mutex}; | |
| 6 | 20 | ||
| 7 | 21 | use axum::{ | |
| 8 | 22 | body::Bytes, | |
| @@ -24,6 +38,29 @@ | |||
| 24 | 38 | /// held to a tight bound, roughly halving the replay window. | |
| 25 | 39 | const MAX_FUTURE_SKEW_SECS: i64 = 5; | |
| 26 | 40 | ||
| 41 | + | /// Process-wide cache of recently-seen request nonces, for single-use | |
| 42 | + | /// enforcement. MT runs as a single process (one `TcpListener`), so a local | |
| 43 | + | /// cache is authoritative. Entries are evicted once older than the freshness | |
| 44 | + | /// window — a request that old is already rejected by the timestamp check, so a | |
| 45 | + | /// nonce can never be replayed after it ages out. Memory is therefore bounded | |
| 46 | + | /// by (request rate × window), and the internal rate limiter caps that. Nonces | |
| 47 | + | /// are inserted only AFTER the signature verifies, so unauthenticated traffic | |
| 48 | + | /// can't poison or grow the cache. | |
| 49 | + | static NONCE_CACHE: LazyLock<Mutex<HashMap<String, i64>>> = | |
| 50 | + | LazyLock::new(|| Mutex::new(HashMap::new())); | |
| 51 | + | ||
| 52 | + | /// Record a nonce as seen. Returns `false` if it was already present within the | |
| 53 | + | /// window (a replay). Sweeps aged entries opportunistically. | |
| 54 | + | fn record_nonce(nonce: &str, now_unix: i64) -> bool { | |
| 55 | + | let mut cache = NONCE_CACHE.lock().unwrap_or_else(|e| e.into_inner()); | |
| 56 | + | cache.retain(|_, &mut ts| now_unix - ts <= MAX_TIMESTAMP_AGE_SECS); | |
| 57 | + | if cache.contains_key(nonce) { | |
| 58 | + | return false; | |
| 59 | + | } | |
| 60 | + | cache.insert(nonce.to_string(), now_unix); | |
| 61 | + | true | |
| 62 | + | } | |
| 63 | + | ||
| 27 | 64 | /// Axum extractor that validates HMAC-SHA256 signatures on internal API requests. | |
| 28 | 65 | /// Extracts the raw request body as `Bytes` after successful verification. | |
| 29 | 66 | pub struct InternalAuth(pub Bytes); | |
| @@ -51,21 +88,42 @@ | |||
| 51 | 88 | .get("X-Internal-Signature") | |
| 52 | 89 | .and_then(|v| v.to_str().ok()) | |
| 53 | 90 | .map(str::to_string); | |
| 91 | + | let nonce_header = req | |
| 92 | + | .headers() | |
| 93 | + | .get("X-Internal-Nonce") | |
| 94 | + | .and_then(|v| v.to_str().ok()) | |
| 95 | + | .map(str::to_string); | |
| 96 | + | // Method + concrete request path (NOT the matched route template) must | |
| 97 | + | // be captured before the body extractor consumes the request. | |
| 98 | + | let method = req.method().as_str().to_string(); | |
| 99 | + | let path = req.uri().path().to_string(); | |
| 54 | 100 | ||
| 55 | 101 | let body = Bytes::from_request(req, state).await.map_err(|e| { | |
| 56 | 102 | tracing::error!(error = %e, "failed to read request body"); | |
| 57 | 103 | StatusCode::BAD_REQUEST.into_response() | |
| 58 | 104 | })?; | |
| 59 | 105 | ||
| 60 | - | verify_internal_signature( | |
| 106 | + | let now = chrono::Utc::now().timestamp(); | |
| 107 | + | verify_signed_request( | |
| 61 | 108 | secret, | |
| 62 | 109 | timestamp_header.as_deref(), | |
| 63 | 110 | signature_header.as_deref(), | |
| 111 | + | &method, | |
| 112 | + | &path, | |
| 113 | + | nonce_header.as_deref(), | |
| 64 | 114 | &body, | |
| 65 | - | chrono::Utc::now().timestamp(), | |
| 115 | + | now, | |
| 66 | 116 | ) | |
| 67 | 117 | .map_err(|(status, msg)| (status, msg).into_response())?; | |
| 68 | 118 | ||
| 119 | + | // Single-use: reject a replayed nonce (only meaningful once the v2 | |
| 120 | + | // signer is live; v1 requests carry no nonce). | |
| 121 | + | if let Some(nonce) = nonce_header.as_deref() | |
| 122 | + | && !record_nonce(nonce, now) | |
| 123 | + | { | |
| 124 | + | return Err((StatusCode::UNAUTHORIZED, "Replayed nonce").into_response()); | |
| 125 | + | } | |
| 126 | + | ||
| 69 | 127 | Ok(InternalAuth(body)) | |
| 70 | 128 | } | |
| 71 | 129 | } | |
| @@ -86,8 +144,51 @@ | |||
| 86 | 144 | hex::encode(mac.finalize().into_bytes()) | |
| 87 | 145 | } | |
| 88 | 146 | ||
| 89 | - | /// Pure verification: validate timestamp freshness against `now_unix`, then | |
| 90 | - | /// recompute the signature and constant-time compare. | |
| 147 | + | /// Compute the v2 signature, which binds method + path + nonce in addition to | |
| 148 | + | /// timestamp + body. The canonical message is newline-delimited with a fixed | |
| 149 | + | /// field order, body last so an embedded newline in the body can never be | |
| 150 | + | /// confused with a field separator: | |
| 151 | + | /// `timestamp \n METHOD \n PATH \n NONCE \n <raw body bytes>` | |
| 152 | + | /// METHOD is uppercase ASCII, PATH is the request path only (no query string). | |
| 153 | + | pub(crate) fn compute_internal_signature_v2( | |
| 154 | + | secret: &str, | |
| 155 | + | timestamp_str: &str, | |
| 156 | + | method: &str, | |
| 157 | + | path: &str, | |
| 158 | + | nonce: &str, | |
| 159 | + | body: &[u8], | |
| 160 | + | ) -> String { | |
| 161 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 162 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 163 | + | mac.update(timestamp_str.as_bytes()); | |
| 164 | + | mac.update(b"\n"); | |
| 165 | + | mac.update(method.as_bytes()); | |
| 166 | + | mac.update(b"\n"); | |
| 167 | + | mac.update(path.as_bytes()); | |
| 168 | + | mac.update(b"\n"); | |
| 169 | + | mac.update(nonce.as_bytes()); | |
| 170 | + | mac.update(b"\n"); | |
| 171 | + | mac.update(body); | |
| 172 | + | hex::encode(mac.finalize().into_bytes()) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// Validate timestamp freshness against `now_unix`. Returns the parsed timestamp. | |
| 176 | + | fn check_freshness(timestamp_str: &str, now_unix: i64) -> Result<i64, (StatusCode, &'static str)> { | |
| 177 | + | let timestamp: i64 = timestamp_str | |
| 178 | + | .parse() | |
| 179 | + | .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?; | |
| 180 | + | if now_unix - timestamp > MAX_TIMESTAMP_AGE_SECS { | |
| 181 | + | return Err((StatusCode::UNAUTHORIZED, "Timestamp too old")); | |
| 182 | + | } | |
| 183 | + | if timestamp - now_unix > MAX_FUTURE_SKEW_SECS { | |
| 184 | + | return Err((StatusCode::UNAUTHORIZED, "Timestamp too far in the future")); | |
| 185 | + | } | |
| 186 | + | Ok(timestamp) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// Pure verification of the legacy (v1) message: timestamp + body only. | |
| 190 | + | /// Retained as the back-compat path during the lockstep rollout (a request with | |
| 191 | + | /// no `X-Internal-Nonce` is assumed to come from a pre-upgrade signer). | |
| 91 | 192 | /// | |
| 92 | 193 | /// Headers are passed as `Option<&str>` so callers can extract them with any | |
| 93 | 194 | /// strategy (axum `HeaderMap`, manual `Bytes`, tests). | |
| @@ -103,29 +204,64 @@ | |||
| 103 | 204 | let signature = signature_header | |
| 104 | 205 | .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?; | |
| 105 | 206 | ||
| 106 | - | let timestamp: i64 = timestamp_str | |
| 107 | - | .parse() | |
| 108 | - | .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?; | |
| 109 | - | ||
| 110 | - | if now_unix - timestamp > MAX_TIMESTAMP_AGE_SECS { | |
| 111 | - | return Err((StatusCode::UNAUTHORIZED, "Timestamp too old")); | |
| 112 | - | } | |
| 113 | - | if timestamp - now_unix > MAX_FUTURE_SKEW_SECS { | |
| 114 | - | return Err((StatusCode::UNAUTHORIZED, "Timestamp too far in the future")); | |
| 115 | - | } | |
| 207 | + | check_freshness(timestamp_str, now_unix)?; | |
| 116 | 208 | ||
| 117 | 209 | let expected = compute_internal_signature(secret, timestamp_str, body); | |
| 118 | - | ||
| 119 | 210 | if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { | |
| 120 | 211 | return Err((StatusCode::UNAUTHORIZED, "Invalid signature")); | |
| 121 | 212 | } | |
| 122 | 213 | Ok(()) | |
| 123 | 214 | } | |
| 124 | 215 | ||
| 125 | - | /// Verify HMAC-SHA256 headers on an internal request (for GET endpoints without a body extractor). | |
| 216 | + | /// Verify a signed internal request, binding method + path + nonce when the | |
| 217 | + | /// signer supplied a nonce (the v2 format), and falling back to the legacy v1 | |
| 218 | + | /// message otherwise. | |
| 219 | + | /// | |
| 220 | + | /// This is the dual-accept transition state: MT accepts both an upgraded signer | |
| 221 | + | /// (nonce present → v2 + replay protection) and a pre-upgrade signer (no nonce → | |
| 222 | + | /// v1). Once the server signer is fully rolled out, tighten this to require a | |
| 223 | + | /// nonce and drop the v1 branch. Freshness is always checked first. | |
| 224 | + | /// | |
| 225 | + | /// Nonce replay is NOT checked here (that is stateful); the caller records the | |
| 226 | + | /// nonce via [`record_nonce`] after this returns Ok. | |
| 227 | + | #[allow(clippy::too_many_arguments)] | |
| 228 | + | pub(crate) fn verify_signed_request( | |
| 229 | + | secret: &str, | |
| 230 | + | timestamp_header: Option<&str>, | |
| 231 | + | signature_header: Option<&str>, | |
| 232 | + | method: &str, | |
| 233 | + | path: &str, | |
| 234 | + | nonce_header: Option<&str>, | |
| 235 | + | body: &[u8], | |
| 236 | + | now_unix: i64, | |
| 237 | + | ) -> Result<(), (StatusCode, &'static str)> { | |
| 238 | + | let Some(nonce) = nonce_header else { | |
| 239 | + | // No nonce → legacy signer. Verify the v1 message for back-compat. | |
| 240 | + | return verify_internal_signature(secret, timestamp_header, signature_header, body, now_unix); | |
| 241 | + | }; | |
| 242 | + | ||
| 243 | + | let timestamp_str = timestamp_header | |
| 244 | + | .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?; | |
| 245 | + | let signature = signature_header | |
| 246 | + | .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?; | |
| 247 | + | ||
| 248 | + | check_freshness(timestamp_str, now_unix)?; | |
| 249 | + | ||
| 250 | + | let expected = compute_internal_signature_v2(secret, timestamp_str, method, path, nonce, body); | |
| 251 | + | if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { | |
| 252 | + | return Err((StatusCode::UNAUTHORIZED, "Invalid signature")); | |
| 253 | + | } | |
| 254 | + | Ok(()) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// Verify HMAC headers on an internal request that has no body extractor (GET | |
| 258 | + | /// endpoints). `method` and `path` bind the v2 signature; pass the concrete | |
| 259 | + | /// request path (e.g. via `OriginalUri`), never a route template. | |
| 126 | 260 | pub fn verify_hmac_headers( | |
| 127 | 261 | state: &AppState, | |
| 128 | 262 | headers: &axum::http::HeaderMap, | |
| 263 | + | method: &str, | |
| 264 | + | path: &str, | |
| 129 | 265 | body: &[u8], | |
| 130 | 266 | ) -> Result<(), (StatusCode, &'static str)> { | |
| 131 | 267 | let secret = state | |
| @@ -143,14 +279,26 @@ | |||
| 143 | 279 | let signature_header = headers | |
| 144 | 280 | .get("X-Internal-Signature") | |
| 145 | 281 | .and_then(|v| v.to_str().ok()); | |
| 282 | + | let nonce_header = headers.get("X-Internal-Nonce").and_then(|v| v.to_str().ok()); | |
| 146 | 283 | ||
| 147 | - | verify_internal_signature( | |
| 284 | + | let now = chrono::Utc::now().timestamp(); | |
| 285 | + | verify_signed_request( | |
| 148 | 286 | secret, | |
| 149 | 287 | timestamp_header, | |
| 150 | 288 | signature_header, | |
| 289 | + | method, | |
| 290 | + | path, | |
| 291 | + | nonce_header, | |
| 151 | 292 | body, | |
| 152 | - | chrono::Utc::now().timestamp(), | |
| 153 | - | ) | |
| 293 | + | now, | |
| 294 | + | )?; | |
| 295 | + | ||
| 296 | + | if let Some(nonce) = nonce_header | |
| 297 | + | && !record_nonce(nonce, now) | |
| 298 | + | { | |
| 299 | + | return Err((StatusCode::UNAUTHORIZED, "Replayed nonce")); | |
| 300 | + | } | |
| 301 | + | Ok(()) | |
| 154 | 302 | } | |
| 155 | 303 | ||
| 156 | 304 | /// Constant-time byte comparison to prevent timing attacks. | |
| @@ -354,4 +502,78 @@ | |||
| 354 | 502 | verify_internal_signature(secret, Some(ts), Some(&sig), body, 9999).unwrap_err(); | |
| 355 | 503 | assert!(msg.contains("Timestamp"), "expected freshness msg, got: {msg}"); | |
| 356 | 504 | } | |
| 505 | + | ||
| 506 | + | // ── v2 (method+path+nonce) verification + nonce replay ── | |
| 507 | + | ||
| 508 | + | fn v2(secret: &str, ts: &str, method: &str, path: &str, nonce: &str, body: &[u8]) -> String { | |
| 509 | + | compute_internal_signature_v2(secret, ts, method, path, nonce, body) | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | #[test] | |
| 513 | + | fn verify_v2_accepts_matching_method_path_nonce() { | |
| 514 | + | let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body"); | |
| 515 | + | assert!(verify_signed_request( | |
| 516 | + | "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("abc"), b"body", 1000 | |
| 517 | + | ) | |
| 518 | + | .is_ok()); | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | #[test] | |
| 522 | + | fn verify_v2_rejects_wrong_method() { | |
| 523 | + | let sig = v2("s", "1000", "GET", "/internal/x", "abc", b"body"); | |
| 524 | + | assert!(verify_signed_request( | |
| 525 | + | "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("abc"), b"body", 1000 | |
| 526 | + | ) | |
| 527 | + | .is_err()); | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | #[test] | |
| 531 | + | fn verify_v2_rejects_wrong_path() { | |
| 532 | + | let sig = v2("s", "1000", "POST", "/internal/a", "abc", b"body"); | |
| 533 | + | assert!(verify_signed_request( | |
| 534 | + | "s", Some("1000"), Some(&sig), "POST", "/internal/b", Some("abc"), b"body", 1000 | |
| 535 | + | ) | |
| 536 | + | .is_err()); | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | #[test] | |
| 540 | + | fn verify_v2_rejects_wrong_nonce() { | |
| 541 | + | let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body"); | |
| 542 | + | assert!(verify_signed_request( | |
| 543 | + | "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("zzz"), b"body", 1000 | |
| 544 | + | ) | |
| 545 | + | .is_err()); | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | #[test] | |
| 549 | + | fn verify_v2_freshness_still_enforced() { | |
| 550 | + | let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body"); | |
| 551 | + | let (_, msg) = verify_signed_request( | |
| 552 | + | "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("abc"), b"body", 9999, | |
| 553 | + | ) | |
| 554 | + | .unwrap_err(); | |
| 555 | + | assert!(msg.contains("Timestamp")); | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | #[test] | |
| 559 | + | fn verify_without_nonce_falls_back_to_v1() { | |
| 560 | + | // A request with no nonce is verified against the legacy v1 message | |
| 561 | + | // (timestamp+body), ignoring method/path — the dual-accept path. | |
| 562 | + | let v1_sig = compute_internal_signature("s", "1000", b"body"); | |
| 563 | + | assert!(verify_signed_request( | |
| 564 | + | "s", Some("1000"), Some(&v1_sig), "POST", "/internal/x", None, b"body", 1000 | |
| 565 | + | ) | |
| 566 | + | .is_ok()); | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | #[test] | |
| 570 | + | fn record_nonce_rejects_replay_and_evicts_aged() { | |
| 571 | + | // Unique nonces so the shared cache can't collide with other tests. | |
| 572 | + | let n1 = "nonce-test-unique-aaa"; | |
| 573 | + | assert!(record_nonce(n1, 1_000_000), "first use accepted"); | |
| 574 | + | assert!(!record_nonce(n1, 1_000_000), "replay within window rejected"); | |
| 575 | + | // Past the freshness window, the entry is swept and the nonce is free | |
| 576 | + | // again (a request that old is already rejected by the timestamp check). | |
| 577 | + | assert!(record_nonce(n1, 1_000_000 + MAX_TIMESTAMP_AGE_SECS + 1), "aged nonce reusable"); | |
| 578 | + | } | |
| 357 | 579 | } |
| @@ -63,6 +63,15 @@ | |||
| 63 | 63 | // -- OAuth -- | |
| 64 | 64 | pub const OAUTH_CODE_EXPIRY_SECS: i64 = 600; // 10 minutes | |
| 65 | 65 | pub const OAUTH_CODE_LENGTH: usize = 32; // 32 bytes = 64 hex chars | |
| 66 | + | /// Lifetime of an OAuth userinfo-scoped access token. Short by design: it is | |
| 67 | + | /// used transiently at callback / refresh for one userinfo fetch and never | |
| 68 | + | /// persisted by a well-behaved RP, so it never needs to outlive a request. | |
| 69 | + | pub const OAUTH_ACCESS_TOKEN_EXPIRY_SECS: i64 = 300; // 5 minutes | |
| 70 | + | /// Lifetime of a rotating OAuth refresh token. The RP stores this (not the | |
| 71 | + | /// access token); each use rotates it. Long enough that perk-refresh keeps | |
| 72 | + | /// working across the RP's own session window without forcing re-login. | |
| 73 | + | pub const OAUTH_REFRESH_TOKEN_EXPIRY_SECS: i64 = 30 * 24 * 3600; // 30 days | |
| 74 | + | pub const OAUTH_REFRESH_TOKEN_LENGTH: usize = 32; // 32 bytes = 64 hex chars | |
| 66 | 75 | ||
| 67 | 76 | // -- Health monitoring -- | |
| 68 | 77 | pub const HEALTH_CHECK_INTERVAL_SECS: u64 = 60; | |
| @@ -342,6 +351,12 @@ | |||
| 342 | 351 | const _: () = assert!(MAX_LOGIN_ATTEMPTS > 0); | |
| 343 | 352 | const _: () = assert!(LOCKOUT_MINUTES > 0); | |
| 344 | 353 | ||
| 354 | + | // OAuth token lifetimes: access tokens are transient, refresh tokens long-lived. | |
| 355 | + | const _: () = assert!(OAUTH_ACCESS_TOKEN_EXPIRY_SECS > 0); | |
| 356 | + | const _: () = assert!(OAUTH_ACCESS_TOKEN_EXPIRY_SECS < SYNCKIT_JWT_EXPIRY_SECS); | |
| 357 | + | const _: () = assert!(OAUTH_REFRESH_TOKEN_EXPIRY_SECS > OAUTH_ACCESS_TOKEN_EXPIRY_SECS); | |
| 358 | + | const _: () = assert!(OAUTH_REFRESH_TOKEN_LENGTH >= 32); | |
| 359 | + | ||
| 345 | 360 | // Email link expiry ordering | |
| 346 | 361 | const _: () = assert!(PASSWORD_RESET_EXPIRY_SECS > 0); | |
| 347 | 362 | const _: () = assert!(EMAIL_VERIFICATION_EXPIRY_SECS > PASSWORD_RESET_EXPIRY_SECS); |
| @@ -24,6 +24,7 @@ | |||
| 24 | 24 | pub mod monitor; | |
| 25 | 25 | pub mod openapi; | |
| 26 | 26 | pub mod mt_client; | |
| 27 | + | pub mod oauth_scope; | |
| 27 | 28 | pub mod wam_client; | |
| 28 | 29 | pub mod payments; | |
| 29 | 30 | pub mod pricing; |
| @@ -426,6 +426,17 @@ | |||
| 426 | 426 | } | |
| 427 | 427 | _ => {} | |
| 428 | 428 | } | |
| 429 | + | ||
| 430 | + | // Clean up expired/used/revoked OAuth refresh tokens | |
| 431 | + | match db::oauth::cleanup_expired_refresh_tokens(&state.db).await { | |
| 432 | + | Ok(deleted) if deleted > 0 => { | |
| 433 | + | tracing::info!(deleted = deleted, "cleaned up expired OAuth refresh tokens"); | |
| 434 | + | } | |
| 435 | + | Err(e) => { | |
| 436 | + | tracing::warn!(error = ?e, "failed to clean up OAuth refresh tokens"); | |
| 437 | + | } | |
| 438 | + | _ => {} | |
| 439 | + | } | |
| 429 | 440 | } | |
| 430 | 441 | } | |
| 431 | 442 | }) |
| @@ -102,17 +102,29 @@ | |||
| 102 | 102 | } | |
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | - | /// Sign a request body, returning (timestamp, hex-encoded signature). | |
| 106 | - | fn sign_request(&self, body: &str) -> (String, String) { | |
| 105 | + | /// Sign a request, binding method + path + a fresh nonce in addition to the | |
| 106 | + | /// timestamp and body. Returns (timestamp, nonce, hex signature). The | |
| 107 | + | /// canonical message — `timestamp \n METHOD \n PATH \n NONCE \n body` — must | |
| 108 | + | /// match MT's `compute_internal_signature_v2` byte-for-byte. `path` is the | |
| 109 | + | /// request path only (no scheme/host, no query string). | |
| 110 | + | fn sign_request(&self, method: &str, path: &str, body: &str) -> (String, String, String) { | |
| 107 | 111 | let timestamp = chrono::Utc::now().timestamp().to_string(); | |
| 108 | - | let message = format!("{}\n{}", timestamp, body); | |
| 112 | + | let nonce = Uuid::new_v4().simple().to_string(); | |
| 109 | 113 | ||
| 110 | 114 | let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.as_bytes()) | |
| 111 | 115 | .expect("HMAC-SHA256 accepts any key length"); | |
| 112 | - | mac.update(message.as_bytes()); | |
| 116 | + | mac.update(timestamp.as_bytes()); | |
| 117 | + | mac.update(b"\n"); | |
| 118 | + | mac.update(method.as_bytes()); | |
| 119 | + | mac.update(b"\n"); | |
| 120 | + | mac.update(path.as_bytes()); | |
| 121 | + | mac.update(b"\n"); | |
| 122 | + | mac.update(nonce.as_bytes()); | |
| 123 | + | mac.update(b"\n"); | |
| 124 | + | mac.update(body.as_bytes()); | |
| 113 | 125 | let signature = hex::encode(mac.finalize().into_bytes()); | |
| 114 | 126 | ||
| 115 | - | (timestamp, signature) | |
| 127 | + | (timestamp, nonce, signature) | |
| 116 | 128 | } | |
| 117 | 129 | ||
| 118 | 130 | /// Send a signed POST request and deserialize the response. | |
| @@ -122,7 +134,7 @@ | |||
| 122 | 134 | req: &Req, | |
| 123 | 135 | ) -> Result<Resp, MtClientError> { | |
| 124 | 136 | let body = serde_json::to_string(req).expect("request serialization cannot fail"); | |
| 125 | - | let (timestamp, signature) = self.sign_request(&body); | |
| 137 | + | let (timestamp, nonce, signature) = self.sign_request("POST", path, &body); | |
| 126 | 138 | ||
| 127 | 139 | let resp = self | |
| 128 | 140 | .http | |
| @@ -130,6 +142,7 @@ | |||
| 130 | 142 | .header("Content-Type", "application/json") | |
| 131 | 143 | .header("X-Internal-Timestamp", ×tamp) | |
| 132 | 144 | .header("X-Internal-Signature", &signature) | |
| 145 | + | .header("X-Internal-Nonce", &nonce) | |
| 133 | 146 | .body(body) | |
| 134 | 147 | .send() | |
| 135 | 148 | .await | |
| @@ -178,15 +191,14 @@ | |||
| 178 | 191 | &self, | |
| 179 | 192 | thread_id: MtThreadId, | |
| 180 | 193 | ) -> Result<ThreadStatsResponse, MtClientError> { | |
| 181 | - | let (timestamp, signature) = self.sign_request(""); | |
| 194 | + | let path = format!("/internal/threads/{}/stats", thread_id); | |
| 195 | + | let (timestamp, nonce, signature) = self.sign_request("GET", &path, ""); | |
| 182 | 196 | let resp = self | |
| 183 | 197 | .http | |
| 184 | - | .get(format!( | |
| 185 | - | "{}/internal/threads/{}/stats", | |
| 186 | - | self.base_url, thread_id | |
| 187 | - | )) | |
| 198 | + | .get(format!("{}{}", self.base_url, path)) | |
| 188 | 199 | .header("X-Internal-Timestamp", ×tamp) | |
| 189 | 200 | .header("X-Internal-Signature", &signature) | |
| 201 | + | .header("X-Internal-Nonce", &nonce) | |
| 190 | 202 | .send() | |
| 191 | 203 | .await | |
| 192 | 204 | .map_err(MtClientError::Unreachable)?; | |
| @@ -209,19 +221,46 @@ | |||
| 209 | 221 | use super::*; | |
| 210 | 222 | ||
| 211 | 223 | #[test] | |
| 212 | - | fn sign_request_produces_deterministic_output() { | |
| 224 | + | fn sign_request_produces_valid_signature_and_fresh_nonce() { | |
| 213 | 225 | let client = MtClient::new("http://localhost".to_string(), "test-secret".to_string()); | |
| 214 | 226 | let body = r#"{"name":"test"}"#; | |
| 215 | - | let (ts1, sig1) = client.sign_request(body); | |
| 216 | - | let (ts2, sig2) = client.sign_request(body); | |
| 227 | + | let (ts1, nonce1, sig1) = client.sign_request("POST", "/internal/communities", body); | |
| 228 | + | let (ts2, nonce2, sig2) = client.sign_request("POST", "/internal/communities", body); | |
| 217 | 229 | ||
| 218 | - | // Timestamps should be within 1 second of each other | |
| 219 | 230 | let t1: i64 = ts1.parse().unwrap(); | |
| 220 | 231 | let t2: i64 = ts2.parse().unwrap(); | |
| 221 | 232 | assert!((t1 - t2).abs() <= 1); | |
| 222 | 233 | ||
| 223 | - | // Signatures should be valid hex (64 chars for SHA256) | |
| 224 | - | assert_eq!(sig1.len(), 64); | |
| 225 | - | assert_eq!(sig2.len(), 64); | |
| 234 | + | assert_eq!(sig1.len(), 64, "SHA-256 hex is 64 chars"); | |
| 235 | + | assert!(sig1.chars().all(|c| c.is_ascii_hexdigit())); | |
| 236 | + | ||
| 237 | + | // Each request carries a fresh nonce, so even identical method/path/body | |
| 238 | + | // produce a distinct signature — single-use by construction. | |
| 239 | + | assert_ne!(nonce1, nonce2, "nonce must be fresh per request"); | |
| 240 | + | assert_ne!(sig1, sig2, "fresh nonce must change the signature"); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// Recompute the canonical v2 message inline to pin that method, path, and | |
| 244 | + | /// nonce are all bound (a mutation dropping any field would collide). | |
| 245 | + | #[test] | |
| 246 | + | fn signed_message_binds_method_path_nonce() { | |
| 247 | + | use hmac::{Hmac, Mac}; | |
| 248 | + | use sha2::Sha256; | |
| 249 | + | ||
| 250 | + | fn sig(secret: &str, ts: &str, method: &str, path: &str, nonce: &str, body: &str) -> String { | |
| 251 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); | |
| 252 | + | for field in [ts, method, path, nonce] { | |
| 253 | + | mac.update(field.as_bytes()); | |
| 254 | + | mac.update(b"\n"); | |
| 255 | + | } | |
| 256 | + | mac.update(body.as_bytes()); | |
| 257 | + | hex::encode(mac.finalize().into_bytes()) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | let base = sig("s", "100", "POST", "/a", "n1", "body"); | |
| 261 | + | assert_ne!(base, sig("s", "100", "GET", "/a", "n1", "body"), "method bound"); | |
| 262 | + | assert_ne!(base, sig("s", "100", "POST", "/b", "n1", "body"), "path bound"); | |
| 263 | + | assert_ne!(base, sig("s", "100", "POST", "/a", "n2", "body"), "nonce bound"); | |
| 264 | + | assert_ne!(base, sig("s", "100", "POST", "/a", "n1", "body2"), "body bound"); | |
| 226 | 265 | } | |
| 227 | 266 | } |