Skip to main content

max / makenotwork

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