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
Commit: 9e71f3a45610f2b5adc9608217878596b71bb02c
Parent: 3d33f5b
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", &timestamp)
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", &timestamp)
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", &timestamp)
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", &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 @@ 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", &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 @@ 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