max / makenotwork
22 files changed,
+1915 insertions,
-242 deletions
| @@ -111,6 +111,7 @@ Browser Multithreaded MNW (OAuth Provider) | |||
| 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 @@ Browser Multithreaded MNW (OAuth Provider) | |||
| 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 @@ const SESSION_USER_ID: &str = "user_id"; | |||
| 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 @@ impl FromRequestParts<AppState> for PlatformAdmin { | |||
| 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 @@ pub enum UserinfoError { | |||
| 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 @@ async fn fetch_userinfo( | |||
| 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"); | |
| 282 | - | } | |
| 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) | |
| 306 | - | } | |
| 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"); | |
| 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"); | |
| 311 | 377 | } | |
| 312 | - | Err(UserinfoError::Unauthorized) | |
| 378 | + | return Err(UserinfoError::RefreshUnavailable); | |
| 313 | 379 | } | |
| 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 @@ pub async fn login( | |||
| 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. | |
| 417 | + | let url = format!( | |
| 418 | + | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}", | |
| 419 | + | state.config.mnw_base_url, | |
| 420 | + | urlencoding::encode(&state.config.oauth_client_id), | |
| 421 | + | urlencoding::encode(&state.config.oauth_redirect_uri), | |
| 422 | + | urlencoding::encode(&oauth_state), | |
| 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 | + | ||
| 337 | 455 | let url = format!( | |
| 338 | - | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", | |
| 456 | + | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none", | |
| 339 | 457 | state.config.mnw_base_url, | |
| 340 | 458 | urlencoding::encode(&state.config.oauth_client_id), | |
| 341 | 459 | urlencoding::encode(&state.config.oauth_redirect_uri), | |
| 342 | 460 | urlencoding::encode(&oauth_state), | |
| 343 | 461 | urlencoding::encode(&challenge), | |
| 462 | + | urlencoding::encode("profile:read perks:read"), | |
| 344 | 463 | ); | |
| 345 | 464 | ||
| 346 | 465 | Redirect::to(&url) | |
| @@ -373,6 +492,22 @@ pub async fn callback( | |||
| 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 @@ pub async fn callback( | |||
| 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 @@ pub async fn callback( | |||
| 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 @@ pub async fn callback( | |||
| 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 @@ pub async fn refresh( | |||
| 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 @@ const MAX_TIMESTAMP_AGE_SECS: i64 = 60; | |||
| 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 @@ impl FromRequest<AppState> for InternalAuth { | |||
| 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 @@ pub(crate) fn compute_internal_signature(secret: &str, timestamp_str: &str, body | |||
| 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 @@ pub(crate) fn verify_internal_signature( | |||
| 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"))?; | |
| 207 | + | check_freshness(timestamp_str, now_unix)?; | |
| 109 | 208 | ||
| 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")); | |
| 209 | + | let expected = compute_internal_signature(secret, timestamp_str, body); | |
| 210 | + | if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { | |
| 211 | + | return Err((StatusCode::UNAUTHORIZED, "Invalid signature")); | |
| 115 | 212 | } | |
| 213 | + | Ok(()) | |
| 214 | + | } | |
| 116 | 215 | ||
| 117 | - | let expected = compute_internal_signature(secret, timestamp_str, body); | |
| 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)?; | |
| 118 | 249 | ||
| 250 | + | let expected = compute_internal_signature_v2(secret, timestamp_str, method, path, nonce, body); | |
| 119 | 251 | if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { | |
| 120 | 252 | return Err((StatusCode::UNAUTHORIZED, "Invalid signature")); | |
| 121 | 253 | } | |
| 122 | 254 | Ok(()) | |
| 123 | 255 | } | |
| 124 | 256 | ||
| 125 | - | /// Verify HMAC-SHA256 headers on an internal request (for GET endpoints without a body extractor). | |
| 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 @@ pub fn verify_hmac_headers( | |||
| 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 @@ mod tests { | |||
| 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 | } |
| @@ -272,9 +272,12 @@ async fn create_thread( | |||
| 272 | 272 | async fn thread_stats( | |
| 273 | 273 | State(state): State<AppState>, | |
| 274 | 274 | headers: axum::http::HeaderMap, | |
| 275 | + | axum::extract::OriginalUri(uri): axum::extract::OriginalUri, | |
| 275 | 276 | Path(id): Path<String>, | |
| 276 | 277 | ) -> Result<Json<ThreadStatsResponse>, Response> { | |
| 277 | - | crate::internal_auth::verify_hmac_headers(&state, &headers, b"") | |
| 278 | + | // Sign over the concrete request path (OriginalUri), matching what the | |
| 279 | + | // server signer used — never the matched route template. | |
| 280 | + | crate::internal_auth::verify_hmac_headers(&state, &headers, "GET", uri.path(), b"") | |
| 278 | 281 | .map_err(|e| e.into_response())?; | |
| 279 | 282 | ||
| 280 | 283 | let thread_id = Uuid::parse_str(&id).map_err(|_| { |
| @@ -135,6 +135,7 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 135 | 135 | ||
| 136 | 136 | let auth_routes = Router::new() | |
| 137 | 137 | .route("/auth/login", get(auth::login)) | |
| 138 | + | .route("/auth/reverify", get(auth::reverify)) | |
| 138 | 139 | .route("/auth/callback", get(auth::callback)) | |
| 139 | 140 | .route("/auth/logout", post(auth::logout)) | |
| 140 | 141 | .route("/auth/refresh", post(auth::refresh)) |
| @@ -224,8 +224,9 @@ impl TestHarness { | |||
| 224 | 224 | ||
| 225 | 225 | /// Handler for `POST /_test/login` — sets session keys without OAuth. | |
| 226 | 226 | /// | |
| 227 | - | /// Accepts optional `access_token` and `perks` (JSON object) fields to seed the | |
| 228 | - | /// fields normally populated by the OAuth callback. | |
| 227 | + | /// Accepts optional `refresh_token` and `perks` (JSON object) fields to seed the | |
| 228 | + | /// session keys normally populated by the OAuth callback. The RP stores a | |
| 229 | + | /// rotating refresh token (not an access token) at rest — see finding S13. | |
| 229 | 230 | async fn test_login_handler( | |
| 230 | 231 | session: tower_sessions::Session, | |
| 231 | 232 | axum::Json(payload): axum::Json<serde_json::Value>, | |
| @@ -241,8 +242,8 @@ async fn test_login_handler( | |||
| 241 | 242 | let _ = session.insert("user_id", user_id).await; | |
| 242 | 243 | let _ = session.insert("username", username).await; | |
| 243 | 244 | ||
| 244 | - | if let Some(token) = payload.get("access_token").and_then(|v| v.as_str()) { | |
| 245 | - | let _ = session.insert("mnw_access_token", token).await; | |
| 245 | + | if let Some(token) = payload.get("refresh_token").and_then(|v| v.as_str()) { | |
| 246 | + | let _ = session.insert("mnw_refresh_token", token).await; | |
| 246 | 247 | } | |
| 247 | 248 | if let Some(perks) = payload.get("perks") { | |
| 248 | 249 | let _ = session.insert("perks", perks).await; |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | use crate::harness::{HarnessOptions, TestHarness}; | |
| 2 | 2 | use axum::http::StatusCode; | |
| 3 | - | use wiremock::matchers::{header, method, path}; | |
| 3 | + | use wiremock::matchers::{body_partial_json, header, method, path}; | |
| 4 | 4 | use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 5 | 5 | ||
| 6 | 6 | #[tokio::test] | |
| @@ -196,8 +196,10 @@ async fn harness_with_mock_mnw() -> (TestHarness, MockServer) { | |||
| 196 | 196 | (h, mock) | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | - | /// Log in via the test harness and seed access token + perks into the session. | |
| 200 | - | async fn login_with_token(h: &mut TestHarness, username: &str, token: &str, perks: serde_json::Value) -> uuid::Uuid { | |
| 199 | + | /// Log in via the test harness and seed a refresh token + perks into the | |
| 200 | + | /// session. Perk refresh now trades this refresh token for a short-lived access | |
| 201 | + | /// token at `/oauth/token`, then fetches userinfo. | |
| 202 | + | async fn login_with_token(h: &mut TestHarness, username: &str, refresh_token: &str, perks: serde_json::Value) -> uuid::Uuid { | |
| 201 | 203 | let user_id = uuid::Uuid::new_v4(); | |
| 202 | 204 | sqlx::query( | |
| 203 | 205 | "INSERT INTO users (mnw_account_id, username, display_name) \ | |
| @@ -212,27 +214,44 @@ async fn login_with_token(h: &mut TestHarness, username: &str, token: &str, perk | |||
| 212 | 214 | let body = serde_json::json!({ | |
| 213 | 215 | "user_id": user_id.to_string(), | |
| 214 | 216 | "username": username, | |
| 215 | - | "access_token": token, | |
| 217 | + | "refresh_token": refresh_token, | |
| 216 | 218 | "perks": perks, | |
| 217 | 219 | }); | |
| 218 | 220 | h.client.post_json("/_test/login", &body.to_string()).await; | |
| 219 | 221 | user_id | |
| 220 | 222 | } | |
| 221 | 223 | ||
| 224 | + | /// Mount a `POST /oauth/token` refresh-grant responder returning the given | |
| 225 | + | /// access + (rotated) refresh token. | |
| 226 | + | async fn mock_refresh_grant(mock: &MockServer, access_token: &str, new_refresh_token: &str) { | |
| 227 | + | Mock::given(method("POST")) | |
| 228 | + | .and(path("/oauth/token")) | |
| 229 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 230 | + | "access_token": access_token, | |
| 231 | + | "token_type": "Bearer", | |
| 232 | + | "expires_in": 300, | |
| 233 | + | "refresh_token": new_refresh_token, | |
| 234 | + | "scope": "perks:read profile:read offline_access", | |
| 235 | + | }))) | |
| 236 | + | .mount(mock) | |
| 237 | + | .await; | |
| 238 | + | } | |
| 239 | + | ||
| 222 | 240 | #[tokio::test] | |
| 223 | 241 | async fn refresh_updates_perks_from_mnw() { | |
| 224 | 242 | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 225 | 243 | let user_id = login_with_token( | |
| 226 | 244 | &mut h, | |
| 227 | 245 | "refreshuser", | |
| 228 | - | "fake-token", | |
| 246 | + | "rt-original", | |
| 229 | 247 | serde_json::json!({ "fan_plus": false, "is_creator": false }), | |
| 230 | 248 | ) | |
| 231 | 249 | .await; | |
| 232 | 250 | ||
| 251 | + | mock_refresh_grant(&mock, "fresh-access", "rt-rotated").await; | |
| 233 | 252 | Mock::given(method("GET")) | |
| 234 | 253 | .and(path("/oauth/userinfo")) | |
| 235 | - | .and(header("authorization", "Bearer fake-token")) | |
| 254 | + | .and(header("authorization", "Bearer fresh-access")) | |
| 236 | 255 | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 237 | 256 | "user_id": user_id, | |
| 238 | 257 | "username": "refreshuser", | |
| @@ -256,16 +275,61 @@ async fn refresh_updates_perks_from_mnw() { | |||
| 256 | 275 | } | |
| 257 | 276 | ||
| 258 | 277 | #[tokio::test] | |
| 278 | + | async fn refresh_rotates_stored_token() { | |
| 279 | + | // The second refresh must present the ROTATED token, proving the rotation | |
| 280 | + | // was persisted to the session. | |
| 281 | + | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 282 | + | let user_id = login_with_token(&mut h, "rotuser", "rt-1", serde_json::json!({})).await; | |
| 283 | + | ||
| 284 | + | let userinfo_body = serde_json::json!({ | |
| 285 | + | "user_id": user_id, "username": "rotuser", "display_name": null, "avatar_url": null, | |
| 286 | + | "perks": { "fan_plus": false, "is_creator": false, "creator_tier": null }, | |
| 287 | + | }); | |
| 288 | + | Mock::given(method("GET")) | |
| 289 | + | .and(path("/oauth/userinfo")) | |
| 290 | + | .respond_with(ResponseTemplate::new(200).set_body_json(userinfo_body)) | |
| 291 | + | .mount(&mock) | |
| 292 | + | .await; | |
| 293 | + | ||
| 294 | + | // First refresh: presents rt-1, gets rt-2. | |
| 295 | + | Mock::given(method("POST")) | |
| 296 | + | .and(path("/oauth/token")) | |
| 297 | + | .and(body_partial_json(serde_json::json!({ "refresh_token": "rt-1" }))) | |
| 298 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 299 | + | "access_token": "acc-1", "token_type": "Bearer", "expires_in": 300, "refresh_token": "rt-2", | |
| 300 | + | }))) | |
| 301 | + | .expect(1) | |
| 302 | + | .mount(&mock) | |
| 303 | + | .await; | |
| 304 | + | let resp = h.client.post_form("/auth/refresh", "").await; | |
| 305 | + | assert_eq!(resp.status, StatusCode::OK); | |
| 306 | + | ||
| 307 | + | // Second refresh must present rt-2 (the rotated token), not rt-1. | |
| 308 | + | Mock::given(method("POST")) | |
| 309 | + | .and(path("/oauth/token")) | |
| 310 | + | .and(body_partial_json(serde_json::json!({ "refresh_token": "rt-2" }))) | |
| 311 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 312 | + | "access_token": "acc-2", "token_type": "Bearer", "expires_in": 300, "refresh_token": "rt-3", | |
| 313 | + | }))) | |
| 314 | + | .expect(1) | |
| 315 | + | .mount(&mock) | |
| 316 | + | .await; | |
| 317 | + | let resp = h.client.post_form("/auth/refresh", "").await; | |
| 318 | + | assert_eq!(resp.status, StatusCode::OK, "second refresh must use rotated token: {}", resp.text); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[tokio::test] | |
| 259 | 322 | async fn refresh_returns_creator_tier_features() { | |
| 260 | 323 | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 261 | 324 | let user_id = login_with_token( | |
| 262 | 325 | &mut h, | |
| 263 | 326 | "creatoruser", | |
| 264 | - | "creator-token", | |
| 327 | + | "creator-rt", | |
| 265 | 328 | serde_json::json!({}), | |
| 266 | 329 | ) | |
| 267 | 330 | .await; | |
| 268 | 331 | ||
| 332 | + | mock_refresh_grant(&mock, "creator-access", "creator-rt-2").await; | |
| 269 | 333 | Mock::given(method("GET")) | |
| 270 | 334 | .and(path("/oauth/userinfo")) | |
| 271 | 335 | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| @@ -293,25 +357,26 @@ async fn refresh_returns_creator_tier_features() { | |||
| 293 | 357 | } | |
| 294 | 358 | ||
| 295 | 359 | #[tokio::test] | |
| 296 | - | async fn refresh_unauthorized_flushes_session() { | |
| 360 | + | async fn refresh_invalid_grant_keeps_session() { | |
| 361 | + | // A dead refresh token must NOT log the user out — the MT session is the | |
| 362 | + | // login-longevity mechanism. (Inverts the old flush-on-unauthorized test.) | |
| 297 | 363 | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 298 | - | login_with_token(&mut h, "expireduser", "stale-token", serde_json::json!({})).await; | |
| 364 | + | login_with_token(&mut h, "expireduser", "dead-rt", serde_json::json!({})).await; | |
| 299 | 365 | ||
| 300 | - | Mock::given(method("GET")) | |
| 301 | - | .and(path("/oauth/userinfo")) | |
| 302 | - | .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({"error": "invalid_token"}))) | |
| 366 | + | Mock::given(method("POST")) | |
| 367 | + | .and(path("/oauth/token")) | |
| 368 | + | .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error": "invalid_grant"}))) | |
| 303 | 369 | .mount(&mock) | |
| 304 | 370 | .await; | |
| 305 | 371 | ||
| 306 | 372 | let resp = h.client.post_form("/auth/refresh", "").await; | |
| 307 | 373 | assert_eq!(resp.status, StatusCode::UNAUTHORIZED); | |
| 308 | 374 | ||
| 309 | - | // Session should be flushed — the home page now shows the login link, not the username. | |
| 375 | + | // Session is intact — the user stays logged in with last-known perks. | |
| 310 | 376 | let resp = h.client.get("/").await; | |
| 311 | - | assert!(resp.text.contains("Login"), "expected login link after session flush"); | |
| 312 | 377 | assert!( | |
| 313 | - | !resp.text.contains("expireduser"), | |
| 314 | - | "username should not appear after flush" | |
| 378 | + | resp.text.contains("expireduser"), | |
| 379 | + | "user must stay logged in after a dead refresh token" | |
| 315 | 380 | ); | |
| 316 | 381 | } | |
| 317 | 382 | ||
| @@ -328,10 +393,10 @@ async fn refresh_without_session_returns_401() { | |||
| 328 | 393 | #[tokio::test] | |
| 329 | 394 | async fn refresh_on_mnw_5xx_returns_bad_gateway() { | |
| 330 | 395 | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 331 | - | login_with_token(&mut h, "transientuser", "any-token", serde_json::json!({})).await; | |
| 396 | + | login_with_token(&mut h, "transientuser", "any-rt", serde_json::json!({})).await; | |
| 332 | 397 | ||
| 333 | - | Mock::given(method("GET")) | |
| 334 | - | .and(path("/oauth/userinfo")) | |
| 398 | + | Mock::given(method("POST")) | |
| 399 | + | .and(path("/oauth/token")) | |
| 335 | 400 | .respond_with(ResponseTemplate::new(503)) | |
| 336 | 401 | .mount(&mock) | |
| 337 | 402 | .await; |
| @@ -55,85 +55,57 @@ impl InternalTestHarness { | |||
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | /// Send a signed POST request to the internal API. | |
| 59 | - | async fn signed_post(&self, uri: &str, body: &str) -> (StatusCode, String) { | |
| 58 | + | /// Send a v2-signed request, binding method + `sign_path` + nonce. The | |
| 59 | + | /// request is sent to `uri`; `sign_path` is what the signature covers — pass | |
| 60 | + | /// the same value normally, or a different one to test path-binding. | |
| 61 | + | async fn send_signed( | |
| 62 | + | &self, | |
| 63 | + | method: Method, | |
| 64 | + | uri: &str, | |
| 65 | + | sign_path: &str, | |
| 66 | + | nonce: &str, | |
| 67 | + | body: &str, | |
| 68 | + | ) -> (StatusCode, String) { | |
| 60 | 69 | let timestamp = chrono::Utc::now().timestamp().to_string(); | |
| 61 | - | let message = format!("{}\n{}", timestamp, body); | |
| 62 | - | let mut mac = | |
| 63 | - | Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key"); | |
| 64 | - | mac.update(message.as_bytes()); | |
| 70 | + | let mut mac = Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key"); | |
| 71 | + | for field in [timestamp.as_str(), method.as_str(), sign_path, nonce] { | |
| 72 | + | mac.update(field.as_bytes()); | |
| 73 | + | mac.update(b"\n"); | |
| 74 | + | } | |
| 75 | + | mac.update(body.as_bytes()); | |
| 65 | 76 | let signature = hex::encode(mac.finalize().into_bytes()); | |
| 66 | 77 | ||
| 67 | - | let mut request = Request::builder() | |
| 68 | - | .method(Method::POST) | |
| 78 | + | let mut builder = Request::builder() | |
| 79 | + | .method(method) | |
| 69 | 80 | .uri(uri) | |
| 70 | - | .header("Content-Type", "application/json") | |
| 71 | 81 | .header("X-Internal-Timestamp", ×tamp) | |
| 72 | 82 | .header("X-Internal-Signature", &signature) | |
| 73 | - | .body(Body::from(body.to_string())) | |
| 74 | - | .expect("build request"); | |
| 83 | + | .header("X-Internal-Nonce", nonce); | |
| 84 | + | if !body.is_empty() { | |
| 85 | + | builder = builder.header("Content-Type", "application/json"); | |
| 86 | + | } | |
| 87 | + | let mut request = builder.body(Body::from(body.to_string())).expect("build request"); | |
| 75 | 88 | ||
| 76 | 89 | request | |
| 77 | 90 | .extensions_mut() | |
| 78 | 91 | .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); | |
| 79 | 92 | ||
| 80 | - | let response = self | |
| 81 | - | .app | |
| 82 | - | .clone() | |
| 83 | - | .oneshot(request) | |
| 84 | - | .await | |
| 85 | - | .expect("send request"); | |
| 86 | - | ||
| 93 | + | let response = self.app.clone().oneshot(request).await.expect("send request"); | |
| 87 | 94 | let status = response.status(); | |
| 88 | - | let bytes = response | |
| 89 | - | .into_body() | |
| 90 | - | .collect() | |
| 91 | - | .await | |
| 92 | - | .expect("read body") | |
| 93 | - | .to_bytes(); | |
| 94 | - | let text = String::from_utf8_lossy(&bytes).to_string(); | |
| 95 | - | ||
| 96 | - | (status, text) | |
| 95 | + | let bytes = response.into_body().collect().await.expect("read body").to_bytes(); | |
| 96 | + | (status, String::from_utf8_lossy(&bytes).to_string()) | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | - | /// Send a signed GET request to the internal API. | |
| 100 | - | async fn get(&self, uri: &str) -> (StatusCode, String) { | |
| 101 | - | let timestamp = chrono::Utc::now().timestamp().to_string(); | |
| 102 | - | let message = format!("{}\n", timestamp); | |
| 103 | - | let mut mac = | |
| 104 | - | Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key"); | |
| 105 | - | mac.update(message.as_bytes()); | |
| 106 | - | let signature = hex::encode(mac.finalize().into_bytes()); | |
| 107 | - | ||
| 108 | - | let mut request = Request::builder() | |
| 109 | - | .method(Method::GET) | |
| 110 | - | .uri(uri) | |
| 111 | - | .header("X-Internal-Timestamp", ×tamp) | |
| 112 | - | .header("X-Internal-Signature", &signature) | |
| 113 | - | .body(Body::empty()) | |
| 114 | - | .expect("build request"); | |
| 115 | - | ||
| 116 | - | request | |
| 117 | - | .extensions_mut() | |
| 118 | - | .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); | |
| 119 | - | ||
| 120 | - | let response = self | |
| 121 | - | .app | |
| 122 | - | .clone() | |
| 123 | - | .oneshot(request) | |
| 124 | - | .await | |
| 125 | - | .expect("send request"); | |
| 99 | + | /// Send a v2-signed POST with a fresh nonce. | |
| 100 | + | async fn signed_post(&self, uri: &str, body: &str) -> (StatusCode, String) { | |
| 101 | + | let nonce = Uuid::new_v4().simple().to_string(); | |
| 102 | + | self.send_signed(Method::POST, uri, uri, &nonce, body).await | |
| 103 | + | } | |
| 126 | 104 | ||
| 127 | - | let status = response.status(); | |
| 128 | - | let bytes = response | |
| 129 | - | .into_body() | |
| 130 | - | .collect() | |
| 131 | - | .await | |
| 132 | - | .expect("read body") | |
| 133 | - | .to_bytes(); | |
| 134 | - | let text = String::from_utf8_lossy(&bytes).to_string(); | |
| 135 | - | ||
| 136 | - | (status, text) | |
| 105 | + | /// Send a v2-signed GET with a fresh nonce. | |
| 106 | + | async fn get(&self, uri: &str) -> (StatusCode, String) { | |
| 107 | + | let nonce = Uuid::new_v4().simple().to_string(); | |
| 108 | + | self.send_signed(Method::GET, uri, uri, &nonce, "").await | |
| 137 | 109 | } | |
| 138 | 110 | } | |
| 139 | 111 | ||
| @@ -540,3 +512,82 @@ async fn create_thread_auto_creates_category() { | |||
| 540 | 512 | assert!(slugs.contains(&"releases"), "Expected 'releases' category, got: {:?}", slugs); | |
| 541 | 513 | assert_eq!(categories.len(), 7); // 6 default + 1 auto-created | |
| 542 | 514 | } | |
| 515 | + | ||
| 516 | + | // ── v2 signing: method/path binding + nonce replay protection ── | |
| 517 | + | ||
| 518 | + | fn community_body(slug: &str) -> String { | |
| 519 | + | serde_json::json!({ | |
| 520 | + | "name": "Replay Project", | |
| 521 | + | "slug": slug, | |
| 522 | + | "description": null, | |
| 523 | + | "owner_mnw_id": Uuid::new_v4(), | |
| 524 | + | "owner_username": "replayowner", | |
| 525 | + | "owner_display_name": null, | |
| 526 | + | }) | |
| 527 | + | .to_string() | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | #[tokio::test] | |
| 531 | + | async fn replayed_nonce_is_rejected() { | |
| 532 | + | let h = InternalTestHarness::new().await; | |
| 533 | + | let body = community_body("replay-proj"); | |
| 534 | + | let nonce = Uuid::new_v4().simple().to_string(); | |
| 535 | + | ||
| 536 | + | // First use of the nonce succeeds. | |
| 537 | + | let (status, text) = h | |
| 538 | + | .send_signed(Method::POST, "/internal/communities", "/internal/communities", &nonce, &body) | |
| 539 | + | .await; | |
| 540 | + | assert_eq!(status, StatusCode::OK, "first request should succeed: {}", text); | |
| 541 | + | ||
| 542 | + | // Replaying the exact same signed request (same nonce) is rejected, even | |
| 543 | + | // though create_community is otherwise idempotent. | |
| 544 | + | let (status, text) = h | |
| 545 | + | .send_signed(Method::POST, "/internal/communities", "/internal/communities", &nonce, &body) | |
| 546 | + | .await; | |
| 547 | + | assert_eq!(status, StatusCode::UNAUTHORIZED, "replayed nonce must be rejected: {}", text); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | #[tokio::test] | |
| 551 | + | async fn signature_bound_to_path() { | |
| 552 | + | let h = InternalTestHarness::new().await; | |
| 553 | + | let body = community_body("wrongpath-proj"); | |
| 554 | + | let nonce = Uuid::new_v4().simple().to_string(); | |
| 555 | + | ||
| 556 | + | // Sign for a different path than the request is sent to → signature mismatch. | |
| 557 | + | let (status, _text) = h | |
| 558 | + | .send_signed(Method::POST, "/internal/communities", "/internal/threads", &nonce, &body) | |
| 559 | + | .await; | |
| 560 | + | assert_eq!(status, StatusCode::UNAUTHORIZED, "path-mismatched signature must be rejected"); | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | #[tokio::test] | |
| 564 | + | async fn signature_bound_to_method() { | |
| 565 | + | let h = InternalTestHarness::new().await; | |
| 566 | + | let nonce = Uuid::new_v4().simple().to_string(); | |
| 567 | + | ||
| 568 | + | // Sign as GET but the stats endpoint is reached via GET with a POST-signed | |
| 569 | + | // message → mismatch. (Sign method "POST", send via GET.) | |
| 570 | + | let id = Uuid::new_v4(); | |
| 571 | + | let uri = format!("/internal/threads/{}/stats", id); | |
| 572 | + | let timestamp = chrono::Utc::now().timestamp().to_string(); | |
| 573 | + | let mut mac = Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key"); | |
| 574 | + | for field in [timestamp.as_str(), "POST", uri.as_str(), nonce.as_str()] { | |
| 575 | + | mac.update(field.as_bytes()); | |
| 576 | + | mac.update(b"\n"); | |
| 577 | + | } | |
| 578 | + | let signature = hex::encode(mac.finalize().into_bytes()); | |
| 579 | + | ||
| 580 | + | let mut request = Request::builder() | |
| 581 | + | .method(Method::GET) | |
| 582 | + | .uri(&uri) | |
| 583 | + | .header("X-Internal-Timestamp", ×tamp) | |
| 584 | + | .header("X-Internal-Signature", &signature) | |
| 585 | + | .header("X-Internal-Nonce", &nonce) | |
| 586 | + | .body(Body::empty()) | |
| 587 | + | .expect("build request"); | |
| 588 | + | request | |
| 589 | + | .extensions_mut() | |
| 590 | + | .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); | |
| 591 | + | let response = h.app.clone().oneshot(request).await.expect("send"); | |
| 592 | + | assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "method-mismatched signature must be rejected"); | |
| 593 | + | } |
| @@ -0,0 +1,34 @@ | |||
| 1 | + | -- OAuth maturation: scopes + rotating refresh tokens (closes MT finding S13). | |
| 2 | + | -- | |
| 3 | + | -- Authorization codes now carry the granted scope so /token can mint a | |
| 4 | + | -- correctly-scoped access token. Empty scope = a legacy client (the desktop | |
| 5 | + | -- sync apps via synckit-client) that sends no `scope` and expects a full sync | |
| 6 | + | -- token; the DEFAULT '' preserves that for codes minted before this migration | |
| 7 | + | -- (in-flight during deploy). Only a non-empty, explicitly-requested scope opts | |
| 8 | + | -- into the new userinfo-scoped token + refresh-token flow. | |
| 9 | + | ALTER TABLE oauth_authorization_codes | |
| 10 | + | ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT ''; | |
| 11 | + | ||
| 12 | + | -- Rotating, scoped refresh tokens. The RP stores one of these (never an access | |
| 13 | + | -- token). Each use rotates: the presented token is marked used and a new token | |
| 14 | + | -- is minted with the same chain_id. Presenting an already-used token is a theft | |
| 15 | + | -- signal -> the whole chain is revoked. Tokens are stored as SHA-256 hashes, | |
| 16 | + | -- never plaintext; lookup is by hash. | |
| 17 | + | CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( | |
| 18 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 19 | + | token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hex of the opaque token | |
| 20 | + | app_id UUID NOT NULL REFERENCES sync_apps(id) ON DELETE CASCADE, | |
| 21 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 22 | + | key TEXT NOT NULL, -- SDK key carried into refreshed access tokens | |
| 23 | + | scope TEXT NOT NULL, | |
| 24 | + | chain_id UUID NOT NULL, -- stable across rotations; revoke-by-chain | |
| 25 | + | expires_at TIMESTAMPTZ NOT NULL, | |
| 26 | + | used_at TIMESTAMPTZ, -- set when rotated/consumed (single-use) | |
| 27 | + | revoked_at TIMESTAMPTZ, -- chain-level or per-token revoke | |
| 28 | + | issued_after TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- compared to users.jwt_invalidated_at | |
| 29 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| 30 | + | ); | |
| 31 | + | ||
| 32 | + | CREATE INDEX IF NOT EXISTS idx_oauth_refresh_chain ON oauth_refresh_tokens(chain_id); | |
| 33 | + | CREATE INDEX IF NOT EXISTS idx_oauth_refresh_user ON oauth_refresh_tokens(user_id); | |
| 34 | + | CREATE INDEX IF NOT EXISTS idx_oauth_refresh_expires ON oauth_refresh_tokens(expires_at); |
| @@ -63,6 +63,15 @@ pub const MIN_SUBSCRIPTION_PRICE_CENTS: i32 = 100; // $1.00 minimum | |||
| 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 @@ const _: () = assert!(SESSION_TOUCH_CACHE_SECS > 0 && SESSION_TOUCH_CACHE_SECS < | |||
| 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); |
| @@ -154,6 +154,7 @@ define_pg_uuid_id!( | |||
| 154 | 154 | SyncBlobId, | |
| 155 | 155 | LoginTokenId, | |
| 156 | 156 | OAuthCodeId, | |
| 157 | + | OAuthRefreshTokenId, | |
| 157 | 158 | UserSessionId, | |
| 158 | 159 | CategoryId, | |
| 159 | 160 | PasskeyId, |
| @@ -59,11 +59,35 @@ pub struct DbOAuthCode { | |||
| 59 | 59 | pub code_challenge: String, | |
| 60 | 60 | pub code_challenge_method: String, | |
| 61 | 61 | pub redirect_uri: String, | |
| 62 | + | /// Space-delimited granted scope (e.g. `profile:read perks:read offline_access`). | |
| 63 | + | pub scope: String, | |
| 62 | 64 | pub expires_at: DateTime<Utc>, | |
| 63 | 65 | pub used_at: Option<DateTime<Utc>>, | |
| 64 | 66 | pub created_at: DateTime<Utc>, | |
| 65 | 67 | } | |
| 66 | 68 | ||
| 69 | + | /// A rotating, scoped OAuth refresh token. Stored as a SHA-256 hash; the | |
| 70 | + | /// plaintext only ever exists in the token response and the RP's store. | |
| 71 | + | #[derive(Debug, Clone, FromRow)] | |
| 72 | + | #[allow(dead_code)] // Fields read via sqlx queries and in route handlers | |
| 73 | + | pub struct DbOAuthRefreshToken { | |
| 74 | + | pub id: OAuthRefreshTokenId, | |
| 75 | + | pub token_hash: String, | |
| 76 | + | pub app_id: SyncAppId, | |
| 77 | + | pub user_id: UserId, | |
| 78 | + | pub key: String, | |
| 79 | + | pub scope: String, | |
| 80 | + | /// Stable across rotations — revoking the chain kills every descendant. | |
| 81 | + | pub chain_id: uuid::Uuid, | |
| 82 | + | pub expires_at: DateTime<Utc>, | |
| 83 | + | pub used_at: Option<DateTime<Utc>>, | |
| 84 | + | pub revoked_at: Option<DateTime<Utc>>, | |
| 85 | + | /// Compared to `users.jwt_invalidated_at` so password change / suspend | |
| 86 | + | /// revokes the whole refresh lineage with no separate revocation system. | |
| 87 | + | pub issued_after: DateTime<Utc>, | |
| 88 | + | pub created_at: DateTime<Utc>, | |
| 89 | + | } | |
| 90 | + | ||
| 67 | 91 | // ── Git Issue models ── | |
| 68 | 92 | ||
| 69 | 93 | /// An issue filed against a git repository. |
| @@ -2,12 +2,13 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | 4 | use sqlx::PgPool; | |
| 5 | + | use uuid::Uuid; | |
| 5 | 6 | ||
| 6 | - | use super::models::DbOAuthCode; | |
| 7 | + | use super::models::{DbOAuthCode, DbOAuthRefreshToken}; | |
| 7 | 8 | use super::{SyncAppId, UserId}; | |
| 8 | 9 | use crate::error::Result; | |
| 9 | 10 | ||
| 10 | - | /// Store a new OAuth authorization code. | |
| 11 | + | /// Store a new OAuth authorization code, carrying the granted scope. | |
| 11 | 12 | #[allow(clippy::too_many_arguments)] | |
| 12 | 13 | #[tracing::instrument(skip_all)] | |
| 13 | 14 | pub async fn create_oauth_code( | |
| @@ -18,13 +19,14 @@ pub async fn create_oauth_code( | |||
| 18 | 19 | code_challenge: &str, | |
| 19 | 20 | code_challenge_method: &str, | |
| 20 | 21 | redirect_uri: &str, | |
| 22 | + | scope: &str, | |
| 21 | 23 | expires_at: DateTime<Utc>, | |
| 22 | 24 | ) -> Result<DbOAuthCode> { | |
| 23 | 25 | let row = sqlx::query_as::<_, DbOAuthCode>( | |
| 24 | 26 | r#" | |
| 25 | 27 | INSERT INTO oauth_authorization_codes | |
| 26 | - | (code, app_id, user_id, code_challenge, code_challenge_method, redirect_uri, expires_at) | |
| 27 | - | VALUES ($1, $2, $3, $4, $5, $6, $7) | |
| 28 | + | (code, app_id, user_id, code_challenge, code_challenge_method, redirect_uri, scope, expires_at) | |
| 29 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) | |
| 28 | 30 | RETURNING * | |
| 29 | 31 | "#, | |
| 30 | 32 | ) | |
| @@ -34,6 +36,7 @@ pub async fn create_oauth_code( | |||
| 34 | 36 | .bind(code_challenge) | |
| 35 | 37 | .bind(code_challenge_method) | |
| 36 | 38 | .bind(redirect_uri) | |
| 39 | + | .bind(scope) | |
| 37 | 40 | .bind(expires_at) | |
| 38 | 41 | .fetch_one(pool) | |
| 39 | 42 | .await?; | |
| @@ -41,6 +44,122 @@ pub async fn create_oauth_code( | |||
| 41 | 44 | Ok(row) | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 47 | + | /// Outcome of presenting a refresh token for rotation. | |
| 48 | + | pub enum RefreshRotateOutcome { | |
| 49 | + | /// The token was valid and has just been consumed (marked used). The caller | |
| 50 | + | /// must now mint a replacement in the same `chain_id`. | |
| 51 | + | Valid(Box<DbOAuthRefreshToken>), | |
| 52 | + | /// The token exists but was already used (rotated). This is a reuse/theft | |
| 53 | + | /// signal: the caller must revoke the whole `chain_id`. | |
| 54 | + | Reused { chain_id: Uuid }, | |
| 55 | + | /// Unknown, expired, or already-revoked token — reject without side effects. | |
| 56 | + | Invalid, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Store a new refresh token (hashed). Used both on the authorization-code | |
| 60 | + | /// grant (first issuance) and on every rotation. | |
| 61 | + | #[allow(clippy::too_many_arguments)] | |
| 62 | + | #[tracing::instrument(skip_all)] | |
| 63 | + | pub async fn create_refresh_token( | |
| 64 | + | pool: &PgPool, | |
| 65 | + | token_hash: &str, | |
| 66 | + | app_id: SyncAppId, | |
| 67 | + | user_id: UserId, | |
| 68 | + | key: &str, | |
| 69 | + | scope: &str, | |
| 70 | + | chain_id: Uuid, | |
| 71 | + | expires_at: DateTime<Utc>, | |
| 72 | + | ) -> Result<DbOAuthRefreshToken> { | |
| 73 | + | let row = sqlx::query_as::<_, DbOAuthRefreshToken>( | |
| 74 | + | r#" | |
| 75 | + | INSERT INTO oauth_refresh_tokens | |
| 76 | + | (token_hash, app_id, user_id, key, scope, chain_id, expires_at) | |
| 77 | + | VALUES ($1, $2, $3, $4, $5, $6, $7) | |
| 78 | + | RETURNING * | |
| 79 | + | "#, | |
| 80 | + | ) | |
| 81 | + | .bind(token_hash) | |
| 82 | + | .bind(app_id) | |
| 83 | + | .bind(user_id) | |
| 84 | + | .bind(key) | |
| 85 | + | .bind(scope) | |
| 86 | + | .bind(chain_id) | |
| 87 | + | .bind(expires_at) | |
| 88 | + | .fetch_one(pool) | |
| 89 | + | .await?; | |
| 90 | + | ||
| 91 | + | Ok(row) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// Atomically consume a refresh token by its hash, classifying the outcome. | |
| 95 | + | /// | |
| 96 | + | /// The single `UPDATE ... WHERE used_at IS NULL ...` makes consumption | |
| 97 | + | /// race-free (mirrors [`consume_oauth_code`]). A miss is then disambiguated: | |
| 98 | + | /// an already-used row is a reuse/theft signal (caller revokes the chain), | |
| 99 | + | /// anything else is simply invalid. | |
| 100 | + | #[tracing::instrument(skip_all)] | |
| 101 | + | pub async fn rotate_refresh_token(pool: &PgPool, token_hash: &str) -> Result<RefreshRotateOutcome> { | |
| 102 | + | let consumed = sqlx::query_as::<_, DbOAuthRefreshToken>( | |
| 103 | + | r#" | |
| 104 | + | UPDATE oauth_refresh_tokens | |
| 105 | + | SET used_at = NOW() | |
| 106 | + | WHERE token_hash = $1 | |
| 107 | + | AND used_at IS NULL | |
| 108 | + | AND revoked_at IS NULL | |
| 109 | + | AND expires_at > NOW() | |
| 110 | + | RETURNING * | |
| 111 | + | "#, | |
| 112 | + | ) | |
| 113 | + | .bind(token_hash) | |
| 114 | + | .fetch_optional(pool) | |
| 115 | + | .await?; | |
| 116 | + | ||
| 117 | + | if let Some(row) = consumed { | |
| 118 | + | return Ok(RefreshRotateOutcome::Valid(Box::new(row))); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // No row consumed — was it a replay of an already-rotated token? | |
| 122 | + | let existing: Option<(Uuid, Option<DateTime<Utc>>)> = sqlx::query_as( | |
| 123 | + | "SELECT chain_id, used_at FROM oauth_refresh_tokens WHERE token_hash = $1", | |
| 124 | + | ) | |
| 125 | + | .bind(token_hash) | |
| 126 | + | .fetch_optional(pool) | |
| 127 | + | .await?; | |
| 128 | + | ||
| 129 | + | match existing { | |
| 130 | + | Some((chain_id, Some(_used))) => Ok(RefreshRotateOutcome::Reused { chain_id }), | |
| 131 | + | _ => Ok(RefreshRotateOutcome::Invalid), | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Revoke every refresh token in a chain — the response to a reuse/theft signal. | |
| 136 | + | #[tracing::instrument(skip_all)] | |
| 137 | + | pub async fn revoke_refresh_chain(pool: &PgPool, chain_id: Uuid) -> Result<()> { | |
| 138 | + | sqlx::query( | |
| 139 | + | "UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE chain_id = $1 AND revoked_at IS NULL", | |
| 140 | + | ) | |
| 141 | + | .bind(chain_id) | |
| 142 | + | .execute(pool) | |
| 143 | + | .await?; | |
| 144 | + | Ok(()) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Delete expired or long-used refresh tokens. Called opportunistically from | |
| 148 | + | /// the health monitor loop alongside [`cleanup_expired_oauth_codes`]. | |
| 149 | + | #[tracing::instrument(skip_all)] | |
| 150 | + | pub async fn cleanup_expired_refresh_tokens(pool: &PgPool) -> Result<u64> { | |
| 151 | + | let result = sqlx::query( | |
| 152 | + | "DELETE FROM oauth_refresh_tokens | |
| 153 | + | WHERE expires_at < NOW() | |
| 154 | + | OR (used_at IS NOT NULL AND used_at < NOW() - INTERVAL '1 day') | |
| 155 | + | OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '1 day')", | |
| 156 | + | ) | |
| 157 | + | .execute(pool) | |
| 158 | + | .await?; | |
| 159 | + | ||
| 160 | + | Ok(result.rows_affected()) | |
| 161 | + | } | |
| 162 | + | ||
| 44 | 163 | /// Atomically consume an authorization code: mark it used and return it in one step. | |
| 45 | 164 | /// | |
| 46 | 165 | /// Returns `Some(code)` if the code was valid and successfully consumed, |
| @@ -24,6 +24,7 @@ pub mod metrics; | |||
| 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 @@ pub fn spawn_monitor( | |||
| 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 @@ impl MtClient { | |||
| 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 @@ impl MtClient { | |||
| 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 @@ impl MtClient { | |||
| 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 @@ impl MtClient { | |||
| 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 @@ mod tests { | |||
| 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 | } |
| @@ -0,0 +1,151 @@ | |||
| 1 | + | //! OAuth 2.0 scopes for "Log in with Makenot.work". | |
| 2 | + | //! | |
| 3 | + | //! Scopes bound what an access token can do. The security-critical use is that | |
| 4 | + | //! a token minted for an RP's perk-refresh flow carries only `profile:read` / | |
| 5 | + | //! `perks:read` and a userinfo audience, so it can read identity/entitlements | |
| 6 | + | //! but cannot act as the user on the sync API. `offline_access` is the opt-in | |
| 7 | + | //! that makes the authorization-code grant also issue a refresh token. | |
| 8 | + | ||
| 9 | + | use std::collections::BTreeSet; | |
| 10 | + | use std::fmt; | |
| 11 | + | use std::str::FromStr; | |
| 12 | + | ||
| 13 | + | /// A single recognized OAuth scope. | |
| 14 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | |
| 15 | + | pub enum OAuthScope { | |
| 16 | + | /// Read identity fields (username, display name, avatar) from userinfo. | |
| 17 | + | ProfileRead, | |
| 18 | + | /// Read the `perks` block (Fan+, creator tier) from userinfo. | |
| 19 | + | PerksRead, | |
| 20 | + | /// Issue a refresh token on the authorization-code grant (OIDC name). | |
| 21 | + | Offline, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl OAuthScope { | |
| 25 | + | pub fn as_str(self) -> &'static str { | |
| 26 | + | match self { | |
| 27 | + | OAuthScope::ProfileRead => "profile:read", | |
| 28 | + | OAuthScope::PerksRead => "perks:read", | |
| 29 | + | OAuthScope::Offline => "offline_access", | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl FromStr for OAuthScope { | |
| 35 | + | type Err = (); | |
| 36 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 37 | + | match s { | |
| 38 | + | "profile:read" => Ok(OAuthScope::ProfileRead), | |
| 39 | + | "perks:read" => Ok(OAuthScope::PerksRead), | |
| 40 | + | "offline_access" => Ok(OAuthScope::Offline), | |
| 41 | + | _ => Err(()), | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// The scopes granted to a token, as a canonical set. | |
| 47 | + | #[derive(Debug, Clone, PartialEq, Eq, Default)] | |
| 48 | + | pub struct GrantedScopes(BTreeSet<OAuthScope>); | |
| 49 | + | ||
| 50 | + | impl GrantedScopes { | |
| 51 | + | /// Parse a space-delimited scope string (RFC 6749 §3.3). Unknown scopes are | |
| 52 | + | /// **silently dropped** rather than erroring — forward-compatibility, so a | |
| 53 | + | /// client requesting a scope we don't recognize yet still authenticates | |
| 54 | + | /// with the scopes we do recognize. | |
| 55 | + | pub fn parse(raw: &str) -> Self { | |
| 56 | + | GrantedScopes( | |
| 57 | + | raw.split_whitespace() | |
| 58 | + | .filter_map(|s| OAuthScope::from_str(s).ok()) | |
| 59 | + | .collect(), | |
| 60 | + | ) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The default scopes when an authorize request omits `scope`: read-only | |
| 64 | + | /// userinfo access, but **not** `offline_access` — so a client that never | |
| 65 | + | /// opts in never receives a refresh token. Preserves today's behavior. | |
| 66 | + | pub fn default_userinfo() -> Self { | |
| 67 | + | let mut set = BTreeSet::new(); | |
| 68 | + | set.insert(OAuthScope::ProfileRead); | |
| 69 | + | set.insert(OAuthScope::PerksRead); | |
| 70 | + | GrantedScopes(set) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | pub fn contains(&self, scope: OAuthScope) -> bool { | |
| 74 | + | self.0.contains(&scope) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | pub fn is_empty(&self) -> bool { | |
| 78 | + | self.0.is_empty() | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// True if every scope in `self` is also in `other`. The downgrade-only | |
| 82 | + | /// invariant for refresh: a refresh request may narrow but never widen the | |
| 83 | + | /// scope it was originally granted. | |
| 84 | + | pub fn subset_of(&self, other: &GrantedScopes) -> bool { | |
| 85 | + | self.0.is_subset(&other.0) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | impl fmt::Display for GrantedScopes { | |
| 90 | + | /// Canonical space-joined form, used to store on the code/refresh row and | |
| 91 | + | /// echo in the token response. Deterministic ordering via the BTreeSet. | |
| 92 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 93 | + | let mut first = true; | |
| 94 | + | for scope in &self.0 { | |
| 95 | + | if !first { | |
| 96 | + | f.write_str(" ")?; | |
| 97 | + | } | |
| 98 | + | f.write_str(scope.as_str())?; | |
| 99 | + | first = false; | |
| 100 | + | } | |
| 101 | + | Ok(()) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | #[cfg(test)] | |
| 106 | + | mod tests { | |
| 107 | + | use super::*; | |
| 108 | + | ||
| 109 | + | #[test] | |
| 110 | + | fn parse_round_trips_canonical() { | |
| 111 | + | let s = GrantedScopes::parse("perks:read profile:read"); | |
| 112 | + | // Canonical order is enum order: profile:read before perks:read. | |
| 113 | + | assert_eq!(s.to_string(), "profile:read perks:read"); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn parse_drops_unknown_scopes() { | |
| 118 | + | let s = GrantedScopes::parse("profile:read admin:everything perks:read"); | |
| 119 | + | assert!(s.contains(OAuthScope::ProfileRead)); | |
| 120 | + | assert!(s.contains(OAuthScope::PerksRead)); | |
| 121 | + | assert_eq!(s.to_string(), "profile:read perks:read"); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | #[test] | |
| 125 | + | fn default_has_no_offline() { | |
| 126 | + | let s = GrantedScopes::default_userinfo(); | |
| 127 | + | assert!(s.contains(OAuthScope::ProfileRead)); | |
| 128 | + | assert!(s.contains(OAuthScope::PerksRead)); | |
| 129 | + | assert!(!s.contains(OAuthScope::Offline)); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | #[test] | |
| 133 | + | fn subset_of_enforces_downgrade_only() { | |
| 134 | + | let granted = GrantedScopes::parse("profile:read perks:read offline_access"); | |
| 135 | + | let narrower = GrantedScopes::parse("perks:read"); | |
| 136 | + | let same = GrantedScopes::parse("profile:read perks:read offline_access"); | |
| 137 | + | let wider = GrantedScopes::parse("profile:read perks:read offline_access"); | |
| 138 | + | assert!(narrower.subset_of(&granted)); | |
| 139 | + | assert!(same.subset_of(&granted)); | |
| 140 | + | // A scope not in the grant cannot be requested on refresh. | |
| 141 | + | let escalated = GrantedScopes::parse("perks:read"); | |
| 142 | + | assert!(!granted.subset_of(&escalated)); // granted is wider than escalated | |
| 143 | + | assert!(wider.subset_of(&granted)); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[test] | |
| 147 | + | fn empty_string_parses_empty() { | |
| 148 | + | assert!(GrantedScopes::parse("").is_empty()); | |
| 149 | + | assert!(GrantedScopes::parse(" ").is_empty()); | |
| 150 | + | } | |
| 151 | + | } |
| @@ -5,8 +5,8 @@ | |||
| 5 | 5 | //! See also: `/docs/developer/oauth` | |
| 6 | 6 | ||
| 7 | 7 | use axum::{ | |
| 8 | - | extract::{Query, State}, | |
| 9 | - | http::StatusCode, | |
| 8 | + | extract::{FromRequestParts, Query, State}, | |
| 9 | + | http::{request::Parts, StatusCode}, | |
| 10 | 10 | response::{IntoResponse, Redirect, Response}, | |
| 11 | 11 | routing::get, | |
| 12 | 12 | Form, Json, | |
| @@ -24,7 +24,8 @@ use crate::{ | |||
| 24 | 24 | csrf, | |
| 25 | 25 | db::{self, CreatorTier, SyncAppId, UserId, Username}, | |
| 26 | 26 | error::{AppError, Result}, | |
| 27 | - | synckit_auth, | |
| 27 | + | oauth_scope::{GrantedScopes, OAuthScope}, | |
| 28 | + | synckit_auth::{self, OAuthUser, SyncUser}, | |
| 28 | 29 | templates::OAuthAuthorizeTemplate, | |
| 29 | 30 | AppState, | |
| 30 | 31 | }; | |
| @@ -45,6 +46,11 @@ pub struct AuthorizeQuery { | |||
| 45 | 46 | pub state: Option<String>, | |
| 46 | 47 | pub code_challenge: Option<String>, | |
| 47 | 48 | pub code_challenge_method: Option<String>, | |
| 49 | + | /// Space-delimited requested scope. Absent => default userinfo scopes. | |
| 50 | + | pub scope: Option<String>, | |
| 51 | + | /// OIDC `prompt`. `prompt=none` requests silent re-auth: a code if the MNW | |
| 52 | + | /// session is alive, else `error=login_required` — never an interactive page. | |
| 53 | + | pub prompt: Option<String>, | |
| 48 | 54 | } | |
| 49 | 55 | ||
| 50 | 56 | #[derive(Deserialize)] | |
| @@ -54,6 +60,8 @@ pub struct AuthorizeForm { | |||
| 54 | 60 | pub state: String, | |
| 55 | 61 | pub code_challenge: String, | |
| 56 | 62 | pub code_challenge_method: String, | |
| 63 | + | #[serde(default)] | |
| 64 | + | pub scope: String, | |
| 57 | 65 | pub login: Option<String>, | |
| 58 | 66 | pub password: Option<String>, | |
| 59 | 67 | #[serde(rename = "_csrf")] | |
| @@ -63,13 +71,25 @@ pub struct AuthorizeForm { | |||
| 63 | 71 | #[derive(Deserialize)] | |
| 64 | 72 | pub struct TokenRequest { | |
| 65 | 73 | pub grant_type: String, | |
| 66 | - | pub code: String, | |
| 67 | - | pub redirect_uri: String, | |
| 68 | - | pub code_verifier: String, | |
| 69 | 74 | pub client_id: String, | |
| 70 | 75 | /// Developer-defined SDK key. Identifies which billing slot this session's | |
| 71 | - | /// uploads count against. Required. | |
| 76 | + | /// uploads count against. Required for the authorization_code grant; on a | |
| 77 | + | /// refresh the stored key is carried forward. | |
| 78 | + | #[serde(default)] | |
| 72 | 79 | pub key: String, | |
| 80 | + | // authorization_code grant | |
| 81 | + | #[serde(default)] | |
| 82 | + | pub code: Option<String>, | |
| 83 | + | #[serde(default)] | |
| 84 | + | pub redirect_uri: Option<String>, | |
| 85 | + | #[serde(default)] | |
| 86 | + | pub code_verifier: Option<String>, | |
| 87 | + | // refresh_token grant | |
| 88 | + | #[serde(default)] | |
| 89 | + | pub refresh_token: Option<String>, | |
| 90 | + | /// Optional downgrade-only scope on refresh. | |
| 91 | + | #[serde(default)] | |
| 92 | + | pub scope: Option<String>, | |
| 73 | 93 | } | |
| 74 | 94 | ||
| 75 | 95 | #[derive(Serialize)] | |
| @@ -77,6 +97,11 @@ pub struct TokenResponse { | |||
| 77 | 97 | pub access_token: String, | |
| 78 | 98 | pub token_type: String, | |
| 79 | 99 | pub expires_in: i64, | |
| 100 | + | /// Present only when the grant included `offline_access`. | |
| 101 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 102 | + | pub refresh_token: Option<String>, | |
| 103 | + | /// Space-delimited granted scope. | |
| 104 | + | pub scope: String, | |
| 80 | 105 | pub user_id: UserId, | |
| 81 | 106 | pub app_id: SyncAppId, | |
| 82 | 107 | } | |
| @@ -89,6 +114,77 @@ fn generate_oauth_code() -> String { | |||
| 89 | 114 | hex::encode(bytes) | |
| 90 | 115 | } | |
| 91 | 116 | ||
| 117 | + | /// Generate an opaque refresh token (returned once, then only its hash is kept). | |
| 118 | + | fn generate_refresh_token() -> String { | |
| 119 | + | let mut bytes = [0u8; constants::OAUTH_REFRESH_TOKEN_LENGTH]; | |
| 120 | + | rand::rng().fill_bytes(&mut bytes); | |
| 121 | + | hex::encode(bytes) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// SHA-256 hex of a refresh token. Tokens are stored and looked up by this hash; | |
| 125 | + | /// the plaintext never touches the database. | |
| 126 | + | fn hash_token(token: &str) -> String { | |
| 127 | + | let mut hasher = Sha256::new(); | |
| 128 | + | hasher.update(token.as_bytes()); | |
| 129 | + | hex::encode(hasher.finalize()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Build a `redirect_uri?error=...&state=...` response (OIDC error redirect), | |
| 133 | + | /// used by `prompt=none` when interaction would otherwise be required. | |
| 134 | + | fn redirect_with_error(redirect_uri: &str, state: &str, error_code: &str) -> Response { | |
| 135 | + | let separator = if redirect_uri.contains('?') { "&" } else { "?" }; | |
| 136 | + | let url = format!( | |
| 137 | + | "{}{}error={}&state={}", | |
| 138 | + | redirect_uri, | |
| 139 | + | separator, | |
| 140 | + | urlencoding::encode(error_code), | |
| 141 | + | urlencoding::encode(state), | |
| 142 | + | ); | |
| 143 | + | Redirect::to(&url).into_response() | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Persist an authorization code and build the success redirect back to the RP. | |
| 147 | + | /// Shared by the interactive POST flow and `prompt=none` silent auth so both | |
| 148 | + | /// store scope identically. | |
| 149 | + | #[allow(clippy::too_many_arguments)] | |
| 150 | + | async fn issue_authorization_code( | |
| 151 | + | pool: &sqlx::PgPool, | |
| 152 | + | app_id: SyncAppId, | |
| 153 | + | user_id: UserId, | |
| 154 | + | code_challenge: &str, | |
| 155 | + | code_challenge_method: &str, | |
| 156 | + | redirect_uri: &str, | |
| 157 | + | scope: &GrantedScopes, | |
| 158 | + | state_param: &str, | |
| 159 | + | ) -> Result<Response> { | |
| 160 | + | let code = generate_oauth_code(); | |
| 161 | + | let expires_at = | |
| 162 | + | chrono::Utc::now() + chrono::Duration::seconds(constants::OAUTH_CODE_EXPIRY_SECS); | |
| 163 | + | ||
| 164 | + | db::oauth::create_oauth_code( | |
| 165 | + | pool, | |
| 166 | + | &code, | |
| 167 | + | app_id, | |
| 168 | + | user_id, | |
| 169 | + | code_challenge, | |
| 170 | + | code_challenge_method, | |
| 171 | + | redirect_uri, | |
| 172 | + | &scope.to_string(), | |
| 173 | + | expires_at, | |
| 174 | + | ) | |
| 175 | + | .await?; | |
| 176 | + | ||
| 177 | + | let separator = if redirect_uri.contains('?') { "&" } else { "?" }; | |
| 178 | + | let redirect_url = format!( | |
| 179 | + | "{}{}code={}&state={}", | |
| 180 | + | redirect_uri, | |
| 181 | + | separator, | |
| 182 | + | urlencoding::encode(&code), | |
| 183 | + | urlencoding::encode(state_param), | |
| 184 | + | ); | |
| 185 | + | Ok(Redirect::to(&redirect_url).into_response()) | |
| 186 | + | } | |
| 187 | + | ||
| 92 | 188 | /// Validate that a redirect_uri is allowed. | |
| 93 | 189 | /// | |
| 94 | 190 | /// Localhost callbacks are always permitted. Accepts the three loopback | |
| @@ -134,11 +230,24 @@ fn render_authorize_error( | |||
| 134 | 230 | state: form.state.clone(), | |
| 135 | 231 | code_challenge: form.code_challenge.clone(), | |
| 136 | 232 | code_challenge_method: form.code_challenge_method.clone(), | |
| 233 | + | scope: form.scope.clone(), | |
| 137 | 234 | error_message: Some(error.to_string()), | |
| 138 | 235 | } | |
| 139 | 236 | .into_response() | |
| 140 | 237 | } | |
| 141 | 238 | ||
| 239 | + | /// Whether a session is "validated" for OAuth grants: a present, non-suspended | |
| 240 | + | /// user (checked by `MaybeUserVerified`) that also carries a tracking ID. | |
| 241 | + | /// Legacy sessions predating tracking must re-authenticate via password. | |
| 242 | + | async fn has_validated_session(session: &Session) -> bool { | |
| 243 | + | session | |
| 244 | + | .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY) | |
| 245 | + | .await | |
| 246 | + | .ok() | |
| 247 | + | .flatten() | |
| 248 | + | .is_some() | |
| 249 | + | } | |
| 250 | + | ||
| 142 | 251 | // ── GET /oauth/authorize ── | |
| 143 | 252 | ||
| 144 | 253 | #[tracing::instrument(skip_all, name = "oauth::authorize_get")] | |
| @@ -177,6 +286,37 @@ async fn authorize_get( | |||
| 177 | 286 | return Err(AppError::BadRequest("redirect_uri is not allowed".to_string())); | |
| 178 | 287 | } | |
| 179 | 288 | ||
| 289 | + | // Empty scope (no `scope` param) = a legacy sync client; it will receive a | |
| 290 | + | // full sync token at /token. A non-empty scope opts into the userinfo flow. | |
| 291 | + | let scope = params | |
| 292 | + | .scope | |
| 293 | + | .as_deref() | |
| 294 | + | .map(GrantedScopes::parse) | |
| 295 | + | .unwrap_or_default(); | |
| 296 | + | ||
| 297 | + | // prompt=none: silent re-auth. Issue a code if the MNW session is validated, | |
| 298 | + | // otherwise bounce back with error=login_required — never an interactive page. | |
| 299 | + | if params.prompt.as_deref() == Some("none") { | |
| 300 | + | let validated_session = has_validated_session(&session).await; | |
| 301 | + | let validated = session_user.as_ref().filter(|_| validated_session); | |
| 302 | + | return match validated { | |
| 303 | + | Some(user) => { | |
| 304 | + | issue_authorization_code( | |
| 305 | + | &state.db, | |
| 306 | + | app.id, | |
| 307 | + | user.id, | |
| 308 | + | code_challenge, | |
| 309 | + | code_challenge_method, | |
| 310 | + | redirect_uri, | |
| 311 | + | &scope, | |
| 312 | + | state_param, | |
| 313 | + | ) | |
| 314 | + | .await | |
| 315 | + | } | |
| 316 | + | None => Ok(redirect_with_error(redirect_uri, state_param, "login_required")), | |
| 317 | + | }; | |
| 318 | + | } | |
| 319 | + | ||
| 180 | 320 | let csrf_token = csrf::get_or_create_token(&session).await?; | |
| 181 | 321 | ||
| 182 | 322 | Ok(OAuthAuthorizeTemplate { | |
| @@ -188,6 +328,7 @@ async fn authorize_get( | |||
| 188 | 328 | state: state_param.to_string(), | |
| 189 | 329 | code_challenge: code_challenge.to_string(), | |
| 190 | 330 | code_challenge_method: code_challenge_method.to_string(), | |
| 331 | + | scope: scope.to_string(), | |
| 191 | 332 | error_message: None, | |
| 192 | 333 | } | |
| 193 | 334 | .into_response()) | |
| @@ -239,12 +380,7 @@ async fn authorize_post( | |||
| 239 | 380 | // Session revocation/suspension is checked by MaybeUserVerified at extraction. | |
| 240 | 381 | // For OAuth grants specifically, also require a tracking ID — legacy | |
| 241 | 382 | // sessions predating session tracking must re-authenticate via password. | |
| 242 | - | let has_tracking = session | |
| 243 | - | .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY) | |
| 244 | - | .await | |
| 245 | - | .ok() | |
| 246 | - | .flatten() | |
| 247 | - | .is_some(); | |
| 383 | + | let has_tracking = has_validated_session(&session).await; | |
| 248 | 384 | let validated_session_user = session_user.as_ref().filter(|_| has_tracking); | |
| 249 | 385 | ||
| 250 | 386 | let user_id = if let Some(user) = validated_session_user { | |
| @@ -371,62 +507,126 @@ async fn authorize_post( | |||
| 371 | 507 | user.id | |
| 372 | 508 | }; | |
| 373 | 509 | ||
| 374 | - | // Generate authorization code | |
| 375 | - | let code = generate_oauth_code(); | |
| 376 | - | let expires_at = chrono::Utc::now() | |
| 377 | - | + chrono::Duration::seconds(constants::OAUTH_CODE_EXPIRY_SECS); | |
| 510 | + | // Empty scope = legacy sync client; non-empty opts into the userinfo flow. | |
| 511 | + | let scope = GrantedScopes::parse(&form.scope); | |
| 378 | 512 | ||
| 379 | - | db::oauth::create_oauth_code( | |
| 513 | + | issue_authorization_code( | |
| 380 | 514 | &state.db, | |
| 381 | - | &code, | |
| 382 | 515 | app.id, | |
| 383 | 516 | user_id, | |
| 384 | 517 | &form.code_challenge, | |
| 385 | 518 | &form.code_challenge_method, | |
| 386 | 519 | &form.redirect_uri, | |
| 387 | - | expires_at, | |
| 520 | + | &scope, | |
| 521 | + | &form.state, | |
| 388 | 522 | ) | |
| 389 | - | .await?; | |
| 390 | - | ||
| 391 | - | // Redirect back to the app's callback with URL-encoded parameters | |
| 392 | - | let separator = if form.redirect_uri.contains('?') { "&" } else { "?" }; | |
| 393 | - | let redirect_url = format!( | |
| 394 | - | "{}{}code={}&state={}", | |
| 395 | - | form.redirect_uri, | |
| 396 | - | separator, | |
| 397 | - | urlencoding::encode(&code), | |
| 398 | - | urlencoding::encode(&form.state), | |
| 399 | - | ); | |
| 400 | - | ||
| 401 | - | Ok(Redirect::to(&redirect_url).into_response()) | |
| 523 | + | .await | |
| 402 | 524 | } | |
| 403 | 525 | ||
| 404 | 526 | // ── POST /oauth/token ── | |
| 405 | 527 | ||
| 528 | + | /// OAuth error response in the RFC 6749 §5.2 shape (`{"error":"..."}`), 400. | |
| 529 | + | fn oauth_error(code: &str) -> Response { | |
| 530 | + | ( | |
| 531 | + | StatusCode::BAD_REQUEST, | |
| 532 | + | Json(serde_json::json!({ "error": code })), | |
| 533 | + | ) | |
| 534 | + | .into_response() | |
| 535 | + | } | |
| 536 | + | ||
| 406 | 537 | #[tracing::instrument(skip_all, name = "oauth::token_exchange")] | |
| 407 | 538 | async fn token_exchange( | |
| 408 | 539 | State(state): State<AppState>, | |
| 409 | 540 | axum::Form(req): axum::Form<TokenRequest>, | |
| 410 | - | ) -> Result<impl IntoResponse> { | |
| 411 | - | if req.grant_type != "authorization_code" { | |
| 412 | - | return Err(AppError::BadRequest("grant_type must be 'authorization_code'".to_string())); | |
| 413 | - | } | |
| 414 | - | ||
| 415 | - | crate::validation::validate_synckit_key(&req.key)?; | |
| 416 | - | ||
| 541 | + | ) -> Result<Response> { | |
| 417 | 542 | let secret = state | |
| 418 | 543 | .config | |
| 419 | 544 | .synckit_jwt_secret | |
| 420 | 545 | .as_deref() | |
| 421 | 546 | .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?; | |
| 422 | 547 | ||
| 548 | + | match req.grant_type.as_str() { | |
| 549 | + | "authorization_code" => token_authorization_code(&state, secret, req).await, | |
| 550 | + | "refresh_token" => token_refresh(&state, secret, req).await, | |
| 551 | + | _ => Err(AppError::BadRequest( | |
| 552 | + | "grant_type must be 'authorization_code' or 'refresh_token'".to_string(), | |
| 553 | + | )), | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | /// Build the success body: a short-lived scoped access token, optionally a fresh | |
| 558 | + | /// refresh token (when `offline_access` is granted), and the granted scope. | |
| 559 | + | async fn build_token_response( | |
| 560 | + | state: &AppState, | |
| 561 | + | secret: &str, | |
| 562 | + | user_id: UserId, | |
| 563 | + | app_id: SyncAppId, | |
| 564 | + | key: &str, | |
| 565 | + | scope: &GrantedScopes, | |
| 566 | + | ) -> Result<TokenResponse> { | |
| 567 | + | let access_token = | |
| 568 | + | synckit_auth::create_oauth_access_token(secret, user_id, app_id, key, scope)?; | |
| 569 | + | ||
| 570 | + | // Issue the first refresh token in a new chain when offline_access is granted. | |
| 571 | + | let refresh_token = if scope.contains(OAuthScope::Offline) { | |
| 572 | + | let plaintext = generate_refresh_token(); | |
| 573 | + | let chain_id = uuid::Uuid::new_v4(); | |
| 574 | + | let expires_at = chrono::Utc::now() | |
| 575 | + | + chrono::Duration::seconds(constants::OAUTH_REFRESH_TOKEN_EXPIRY_SECS); | |
| 576 | + | db::oauth::create_refresh_token( | |
| 577 | + | &state.db, | |
| 578 | + | &hash_token(&plaintext), | |
| 579 | + | app_id, | |
| 580 | + | user_id, | |
| 581 | + | key, | |
| 582 | + | &scope.to_string(), | |
| 583 | + | chain_id, | |
| 584 | + | expires_at, | |
| 585 | + | ) | |
| 586 | + | .await?; | |
| 587 | + | Some(plaintext) | |
| 588 | + | } else { | |
| 589 | + | None | |
| 590 | + | }; | |
| 591 | + | ||
| 592 | + | Ok(TokenResponse { | |
| 593 | + | access_token, | |
| 594 | + | token_type: "Bearer".to_string(), | |
| 595 | + | expires_in: constants::OAUTH_ACCESS_TOKEN_EXPIRY_SECS, | |
| 596 | + | refresh_token, | |
| 597 | + | scope: scope.to_string(), | |
| 598 | + | user_id, | |
| 599 | + | app_id, | |
| 600 | + | }) | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | /// authorization_code grant: verify PKCE, then mint a scoped access token (and a | |
| 604 | + | /// refresh token if `offline_access` was granted). | |
| 605 | + | async fn token_authorization_code( | |
| 606 | + | state: &AppState, | |
| 607 | + | secret: &str, | |
| 608 | + | req: TokenRequest, | |
| 609 | + | ) -> Result<Response> { | |
| 610 | + | crate::validation::validate_synckit_key(&req.key)?; | |
| 611 | + | ||
| 612 | + | let code = req | |
| 613 | + | .code | |
| 614 | + | .as_deref() | |
| 615 | + | .ok_or_else(|| AppError::BadRequest("code is required".to_string()))?; | |
| 616 | + | let redirect_uri = req | |
| 617 | + | .redirect_uri | |
| 618 | + | .as_deref() | |
| 619 | + | .ok_or_else(|| AppError::BadRequest("redirect_uri is required".to_string()))?; | |
| 620 | + | let code_verifier = req | |
| 621 | + | .code_verifier | |
| 622 | + | .as_deref() | |
| 623 | + | .ok_or_else(|| AppError::BadRequest("code_verifier is required".to_string()))?; | |
| 624 | + | ||
| 423 | 625 | // Atomically consume code (must exist, not expired, not used). | |
| 424 | - | // Single UPDATE...RETURNING prevents TOCTOU race on concurrent requests. | |
| 425 | - | let oauth_code = db::oauth::consume_oauth_code(&state.db, &req.code) | |
| 626 | + | let oauth_code = db::oauth::consume_oauth_code(&state.db, code) | |
| 426 | 627 | .await? | |
| 427 | 628 | .ok_or(AppError::BadRequest("Invalid or expired authorization code".to_string()))?; | |
| 428 | 629 | ||
| 429 | - | // Verify client_id matches the app that the code was issued for | |
| 430 | 630 | let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.client_id) | |
| 431 | 631 | .await? | |
| 432 | 632 | .ok_or(AppError::BadRequest("Unknown client_id".to_string()))?; | |
| @@ -435,26 +635,19 @@ async fn token_exchange( | |||
| 435 | 635 | return Err(AppError::BadRequest("client_id does not match".to_string())); | |
| 436 | 636 | } | |
| 437 | 637 | ||
| 438 | - | // Verify redirect_uri matches exactly | |
| 439 | - | if req.redirect_uri != oauth_code.redirect_uri { | |
| 638 | + | if redirect_uri != oauth_code.redirect_uri { | |
| 440 | 639 | return Err(AppError::BadRequest("redirect_uri does not match".to_string())); | |
| 441 | 640 | } | |
| 442 | 641 | ||
| 443 | - | // Verify PKCE method matches what the authorize step recorded. We only | |
| 444 | - | // accept S256 at authorize time, but pinning it here too means a future | |
| 445 | - | // change that loosens the authorize check can't silently downgrade | |
| 446 | - | // verification to `plain` (where code_verifier == code_challenge and | |
| 447 | - | // the SHA-256 step below would never run). Defense in depth. | |
| 642 | + | // Pin S256 (defense in depth — see authorize). | |
| 448 | 643 | if oauth_code.code_challenge_method != "S256" { | |
| 449 | 644 | return Err(AppError::BadRequest( | |
| 450 | 645 | "Unsupported PKCE method on authorization code".to_string(), | |
| 451 | 646 | )); | |
| 452 | 647 | } | |
| 453 | 648 | ||
| 454 | - | // Verify PKCE: SHA256(code_verifier) must equal stored code_challenge | |
| 455 | - | // code_challenge is URL-safe base64 no-pad of SHA256(verifier) | |
| 456 | 649 | let mut hasher = Sha256::new(); | |
| 457 | - | hasher.update(req.code_verifier.as_bytes()); | |
| 650 | + | hasher.update(code_verifier.as_bytes()); | |
| 458 | 651 | let digest = hasher.finalize(); | |
| 459 | 652 | let computed_challenge = base64_url_nopad_encode(&digest); | |
| 460 | 653 | ||
| @@ -462,20 +655,126 @@ async fn token_exchange( | |||
| 462 | 655 | return Err(AppError::BadRequest("PKCE verification failed".to_string())); | |
| 463 | 656 | } | |
| 464 | 657 | ||
| 465 | - | let token = synckit_auth::create_sync_token( | |
| 658 | + | let scope = GrantedScopes::parse(&oauth_code.scope); | |
| 659 | + | ||
| 660 | + | // Empty scope = a legacy sync client (desktop apps via synckit-client): mint | |
| 661 | + | // the full 7-day sync token exactly as before. This is the only path that | |
| 662 | + | // still issues a sync-API-capable token from /oauth/token. | |
| 663 | + | if scope.is_empty() { | |
| 664 | + | let token = synckit_auth::create_sync_token( | |
| 665 | + | secret, | |
| 666 | + | oauth_code.user_id, | |
| 667 | + | oauth_code.app_id, | |
| 668 | + | &req.key, | |
| 669 | + | )?; | |
| 670 | + | return Ok(Json(TokenResponse { | |
| 671 | + | access_token: token, | |
| 672 | + | token_type: "Bearer".to_string(), | |
| 673 | + | expires_in: constants::SYNCKIT_JWT_EXPIRY_SECS, | |
| 674 | + | refresh_token: None, | |
| 675 | + | scope: String::new(), | |
| 676 | + | user_id: oauth_code.user_id, | |
| 677 | + | app_id: oauth_code.app_id, | |
| 678 | + | }) | |
| 679 | + | .into_response()); | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | // Scoped request = the userinfo RP flow: short-lived userinfo token, plus a | |
| 683 | + | // refresh token when offline_access was granted. | |
| 684 | + | let resp = | |
| 685 | + | build_token_response(state, secret, oauth_code.user_id, oauth_code.app_id, &req.key, &scope) | |
| 686 | + | .await?; | |
| 687 | + | Ok(Json(resp).into_response()) | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | /// refresh_token grant: rotate the presented token (reuse-detected), re-check | |
| 691 | + | /// revocation/liveness, enforce downgrade-only scope, and mint a fresh pair. | |
| 692 | + | async fn token_refresh(state: &AppState, secret: &str, req: TokenRequest) -> Result<Response> { | |
| 693 | + | let presented = match req.refresh_token.as_deref() { | |
| 694 | + | Some(t) if !t.is_empty() => t, | |
| 695 | + | _ => return Ok(oauth_error("invalid_request")), | |
| 696 | + | }; | |
| 697 | + | ||
| 698 | + | let consumed = match db::oauth::rotate_refresh_token(&state.db, &hash_token(presented)).await? { | |
| 699 | + | db::oauth::RefreshRotateOutcome::Valid(row) => row, | |
| 700 | + | db::oauth::RefreshRotateOutcome::Reused { chain_id } => { | |
| 701 | + | // Theft signal: a rotated token was presented again. Kill the chain. | |
| 702 | + | db::oauth::revoke_refresh_chain(&state.db, chain_id).await?; | |
| 703 | + | return Ok(oauth_error("invalid_grant")); | |
| 704 | + | } | |
| 705 | + | db::oauth::RefreshRotateOutcome::Invalid => return Ok(oauth_error("invalid_grant")), | |
| 706 | + | }; | |
| 707 | + | ||
| 708 | + | // Revocation + liveness: the same gates the access-token extractor applies, | |
| 709 | + | // so password change / suspend / app-deactivate kill the refresh lineage. | |
| 710 | + | let user = db::users::get_user_by_id(&state.db, consumed.user_id).await?; | |
| 711 | + | let live = match user { | |
| 712 | + | Some(u) if !(u.is_suspended() || u.is_deactivated()) => { | |
| 713 | + | u.jwt_invalidated_at | |
| 714 | + | .map(|inv| consumed.issued_after.timestamp() > inv.timestamp()) | |
| 715 | + | .unwrap_or(true) | |
| 716 | + | } | |
| 717 | + | _ => false, | |
| 718 | + | }; | |
| 719 | + | let app = db::synckit::get_sync_app_by_id(&state.db, consumed.app_id).await?; | |
| 720 | + | let app_live = app.map(|a| a.is_active).unwrap_or(false); | |
| 721 | + | if !live || !app_live { | |
| 722 | + | db::oauth::revoke_refresh_chain(&state.db, consumed.chain_id).await?; | |
| 723 | + | return Ok(oauth_error("invalid_grant")); | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | // Downgrade-only scope: a refresh may narrow but never widen. | |
| 727 | + | let stored = GrantedScopes::parse(&consumed.scope); | |
| 728 | + | let granted = match req.scope.as_deref() { | |
| 729 | + | Some(s) if !s.trim().is_empty() => { | |
| 730 | + | let requested = GrantedScopes::parse(s); | |
| 731 | + | if !requested.subset_of(&stored) { | |
| 732 | + | return Ok(oauth_error("invalid_scope")); |
Lines truncated
| @@ -6,9 +6,10 @@ use axum::{extract::FromRequestParts, http::request::Parts}; | |||
| 6 | 6 | use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; | |
| 7 | 7 | use serde::{Deserialize, Serialize}; | |
| 8 | 8 | ||
| 9 | - | use crate::constants::SYNCKIT_JWT_EXPIRY_SECS; | |
| 9 | + | use crate::constants::{OAUTH_ACCESS_TOKEN_EXPIRY_SECS, SYNCKIT_JWT_EXPIRY_SECS}; | |
| 10 | 10 | use crate::db::{SyncAppId, UserId}; | |
| 11 | 11 | use crate::error::{AppError, ResultExt}; | |
| 12 | + | use crate::oauth_scope::GrantedScopes; | |
| 12 | 13 | use crate::AppState; | |
| 13 | 14 | ||
| 14 | 15 | /// Issuer claim value for all SyncKit JWTs. | |
| @@ -19,6 +20,13 @@ const SYNCKIT_JWT_ISSUER: &str = "makenotwork-synckit"; | |||
| 19 | 20 | /// be replayed against the sync API, even if the secret were ever shared. | |
| 20 | 21 | const SYNCKIT_JWT_AUDIENCE: &str = "makenotwork-synckit-clients"; | |
| 21 | 22 | ||
| 23 | + | /// Audience for OAuth userinfo-scoped access tokens. The decisive S13 boundary: | |
| 24 | + | /// these are minted for an RP's perk-refresh flow and accepted only at | |
| 25 | + | /// `/oauth/userinfo`. Because `decode_sync_token` pins the *sync* audience, a | |
| 26 | + | /// userinfo-aud token can never authenticate the sync API — same secret, but a | |
| 27 | + | /// different, non-overlapping audience. | |
| 28 | + | const OAUTH_USERINFO_AUDIENCE: &str = "makenotwork-oauth-userinfo"; | |
| 29 | + | ||
| 22 | 30 | /// JWT claims for SyncKit tokens. | |
| 23 | 31 | #[derive(Debug, Serialize, Deserialize)] | |
| 24 | 32 | pub struct SyncClaims { | |
| @@ -173,14 +181,170 @@ impl FromRequestParts<AppState> for SyncUser { | |||
| 173 | 181 | } | |
| 174 | 182 | } | |
| 175 | 183 | ||
| 184 | + | // ── OAuth userinfo access tokens ── | |
| 185 | + | ||
| 186 | + | /// Claims for a short-lived, scoped OAuth access token. Distinct struct (and | |
| 187 | + | /// audience) from [`SyncClaims`] so the sync API and userinfo can never accept | |
| 188 | + | /// each other's tokens. `scope` is the space-delimited granted scope. | |
| 189 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 190 | + | pub struct OAuthAccessClaims { | |
| 191 | + | pub sub: UserId, | |
| 192 | + | pub app: SyncAppId, | |
| 193 | + | pub key: String, | |
| 194 | + | pub scope: String, | |
| 195 | + | pub iss: String, | |
| 196 | + | pub aud: String, | |
| 197 | + | pub exp: i64, | |
| 198 | + | pub iat: i64, | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// Mint a short-lived OAuth userinfo access token carrying `scopes`. | |
| 202 | + | pub fn create_oauth_access_token( | |
| 203 | + | secret: &str, | |
| 204 | + | user_id: UserId, | |
| 205 | + | app_id: SyncAppId, | |
| 206 | + | key: &str, | |
| 207 | + | scopes: &GrantedScopes, | |
| 208 | + | ) -> Result<String, AppError> { | |
| 209 | + | let now = chrono::Utc::now().timestamp(); | |
| 210 | + | let claims = OAuthAccessClaims { | |
| 211 | + | sub: user_id, | |
| 212 | + | app: app_id, | |
| 213 | + | key: key.to_string(), | |
| 214 | + | scope: scopes.to_string(), | |
| 215 | + | iss: SYNCKIT_JWT_ISSUER.to_string(), | |
| 216 | + | aud: OAUTH_USERINFO_AUDIENCE.to_string(), | |
| 217 | + | exp: now + OAUTH_ACCESS_TOKEN_EXPIRY_SECS, | |
| 218 | + | iat: now, | |
| 219 | + | }; | |
| 220 | + | encode( | |
| 221 | + | &Header::default(), | |
| 222 | + | &claims, | |
| 223 | + | &EncodingKey::from_secret(secret.as_bytes()), | |
| 224 | + | ) | |
| 225 | + | .context("oauth access token encode") | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// Decode and validate an OAuth userinfo access token. Pins the userinfo | |
| 229 | + | /// audience and rejects future-`iat` (same defense-in-depth as | |
| 230 | + | /// [`decode_sync_token`]). | |
| 231 | + | pub fn decode_oauth_access_token(secret: &str, token: &str) -> Result<OAuthAccessClaims, AppError> { | |
| 232 | + | let mut validation = Validation::new(Algorithm::HS256); | |
| 233 | + | validation.set_issuer(&[SYNCKIT_JWT_ISSUER]); | |
| 234 | + | validation.set_audience(&[OAUTH_USERINFO_AUDIENCE]); | |
| 235 | + | ||
| 236 | + | let data = decode::<OAuthAccessClaims>( | |
| 237 | + | token, | |
| 238 | + | &DecodingKey::from_secret(secret.as_bytes()), | |
| 239 | + | &validation, | |
| 240 | + | ) | |
| 241 | + | .map_err(|_| AppError::Unauthorized)?; | |
| 242 | + | ||
| 243 | + | let now = chrono::Utc::now().timestamp(); | |
| 244 | + | if data.claims.iat > now + 60 { | |
| 245 | + | return Err(AppError::Unauthorized); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | Ok(data.claims) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// Authenticated user extracted from an OAuth userinfo access token. Carries the | |
| 252 | + | /// granted scopes so the userinfo handler can gate fields per scope. Applies the | |
| 253 | + | /// same liveness checks as [`SyncUser`] — crucially the `jwt_invalidated_at` | |
| 254 | + | /// gate, so a password change also kills userinfo tokens. | |
| 255 | + | pub struct OAuthUser { | |
| 256 | + | pub user_id: UserId, | |
| 257 | + | pub scopes: GrantedScopes, | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | impl FromRequestParts<AppState> for OAuthUser { | |
| 261 | + | type Rejection = AppError; | |
| 262 | + | ||
| 263 | + | async fn from_request_parts( | |
| 264 | + | parts: &mut Parts, | |
| 265 | + | state: &AppState, | |
| 266 | + | ) -> Result<Self, Self::Rejection> { | |
| 267 | + | let secret = state | |
| 268 | + | .config | |
| 269 | + | .synckit_jwt_secret | |
| 270 | + | .as_deref() | |
| 271 | + | .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?; | |
| 272 | + | ||
| 273 | + | let token = parts | |
| 274 | + | .headers | |
| 275 | + | .get("authorization") | |
| 276 | + | .and_then(|v| v.to_str().ok()) | |
| 277 | + | .and_then(|v| v.strip_prefix("Bearer ")) | |
| 278 | + | .ok_or(AppError::Unauthorized)?; | |
| 279 | + | ||
| 280 | + | let claims = decode_oauth_access_token(secret, token)?; | |
| 281 | + | ||
| 282 | + | let app = crate::db::synckit::get_sync_app_by_id(&state.db, claims.app) | |
| 283 | + | .await? | |
| 284 | + | .ok_or(AppError::Unauthorized)?; | |
| 285 | + | if !app.is_active { | |
| 286 | + | return Err(AppError::Unauthorized); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | let user = crate::db::users::get_user_by_id(&state.db, claims.sub) | |
| 290 | + | .await? | |
| 291 | + | .ok_or(AppError::Unauthorized)?; | |
| 292 | + | if user.is_suspended() || user.is_deactivated() { | |
| 293 | + | return Err(AppError::Unauthorized); | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | if let Some(invalidated_at) = user.jwt_invalidated_at | |
| 297 | + | && claims.iat <= invalidated_at.timestamp() | |
| 298 | + | { | |
| 299 | + | return Err(AppError::Unauthorized); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | Ok(OAuthUser { | |
| 303 | + | user_id: claims.sub, | |
| 304 | + | scopes: GrantedScopes::parse(&claims.scope), | |
| 305 | + | }) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 176 | 309 | #[cfg(test)] | |
| 177 | 310 | mod tests { | |
| 178 | 311 | use super::*; | |
| 312 | + | use crate::oauth_scope::OAuthScope; | |
| 179 | 313 | ||
| 180 | 314 | const TEST_SECRET: &str = "test-secret-key-for-synckit-jwt"; | |
| 181 | 315 | const TEST_KEY: &str = "test-key"; | |
| 182 | 316 | ||
| 183 | 317 | #[test] | |
| 318 | + | fn oauth_access_token_round_trips_scope() { | |
| 319 | + | let scopes = GrantedScopes::parse("profile:read perks:read"); | |
| 320 | + | let token = | |
| 321 | + | create_oauth_access_token(TEST_SECRET, UserId::new(), SyncAppId::new(), TEST_KEY, &scopes) | |
| 322 | + | .unwrap(); | |
| 323 | + | let claims = decode_oauth_access_token(TEST_SECRET, &token).unwrap(); | |
| 324 | + | let got = GrantedScopes::parse(&claims.scope); | |
| 325 | + | assert!(got.contains(OAuthScope::ProfileRead)); | |
| 326 | + | assert!(got.contains(OAuthScope::PerksRead)); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[test] | |
| 330 | + | fn oauth_access_token_rejected_by_sync_decode() { | |
| 331 | + | // The S13 boundary at the unit level: a userinfo-aud token must NOT | |
| 332 | + | // decode as a sync token, so it can never authenticate the sync API. | |
| 333 | + | let scopes = GrantedScopes::parse("perks:read"); | |
| 334 | + | let token = | |
| 335 | + | create_oauth_access_token(TEST_SECRET, UserId::new(), SyncAppId::new(), TEST_KEY, &scopes) | |
| 336 | + | .unwrap(); | |
| 337 | + | assert!(decode_sync_token(TEST_SECRET, &token).is_err()); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn sync_token_rejected_by_oauth_decode() { | |
| 342 | + | // And the reverse: a full sync token isn't a userinfo-aud token. | |
| 343 | + | let token = create_sync_token(TEST_SECRET, UserId::new(), SyncAppId::new(), TEST_KEY).unwrap(); | |
| 344 | + | assert!(decode_oauth_access_token(TEST_SECRET, &token).is_err()); | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | #[test] | |
| 184 | 348 | fn jwt_round_trip() { | |
| 185 | 349 | let user_id = UserId::new(); | |
| 186 | 350 | let app_id = SyncAppId::new(); |
| @@ -203,6 +203,9 @@ pub struct OAuthAuthorizeTemplate { | |||
| 203 | 203 | pub state: String, | |
| 204 | 204 | pub code_challenge: String, | |
| 205 | 205 | pub code_challenge_method: String, | |
| 206 | + | /// Space-delimited requested scope, round-tripped through the consent form | |
| 207 | + | /// so the granted scope survives the GET -> POST hop. | |
| 208 | + | pub scope: String, | |
| 206 | 209 | pub error_message: Option<String>, | |
| 207 | 210 | } | |
| 208 | 211 |