From e7e26efb3e1b8438c6f2b93a1744aaca3358d469 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:07:06 +0530 Subject: [PATCH] =?UTF-8?q?fix(credentials):=20reject=20expired=20session?= =?UTF-8?q?=20locally=20+=20gate=20usage=20polling=20=E2=80=94=20RCA=20for?= =?UTF-8?q?=20backend=20401=20flood=20(#3297)=20(#3384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/hooks/useUsageState.test.ts | 36 ++++ app/src/hooks/useUsageState.ts | 22 ++- src/api/jwt.rs | 72 ++++++++ src/api/mod.rs | 5 +- src/api/rest.rs | 29 ++++ src/api/rest_tests.rs | 66 +++++++- src/core/jsonrpc_tests.rs | 20 +++ src/openhuman/billing/ops.rs | 22 ++- src/openhuman/credentials/ops.rs | 27 +++ src/openhuman/credentials/session_support.rs | 167 +++++++++++++++++++ src/openhuman/team/ops.rs | 42 +++-- 11 files changed, 468 insertions(+), 40 deletions(-) diff --git a/app/src/hooks/useUsageState.test.ts b/app/src/hooks/useUsageState.test.ts index 7d131586d..190283bbb 100644 --- a/app/src/hooks/useUsageState.test.ts +++ b/app/src/hooks/useUsageState.test.ts @@ -20,6 +20,14 @@ vi.mock('../services/api/aiSettingsApi', async () => { return { ...actual, loadAISettings: () => mockLoadAISettings() }; }); +// useUsageState gates polling on auth (#3297). Default authenticated so every +// existing budget-gating assertion keeps exercising the fetch path; the gating +// test below flips it false. +const { mockAuthState } = vi.hoisted(() => ({ mockAuthState: { isAuthenticated: true } })); +vi.mock('../providers/CoreStateProvider', () => ({ + useCoreState: () => ({ snapshot: { auth: { isAuthenticated: mockAuthState.isAuthenticated } } }), +})); + // All chat workloads routed to OpenHuman — the default for every existing // test case (matches the legacy "you have a hosted-backend budget" world). const ALL_OPENHUMAN_AI_SETTINGS = { @@ -123,6 +131,8 @@ describe('useUsageState', () => { mockGetCurrentPlan.mockReset(); mockGetTeamUsage.mockReset(); mockLoadAISettings.mockReset(); + // Default authenticated; the auth-gating test opts out explicitly. + mockAuthState.isAuthenticated = true; // Default: keep the OpenHuman-routed world so every legacy assertion // about budget gating stays identical until a test opts into the // routed-away scenarios below. @@ -658,4 +668,30 @@ describe('useUsageState', () => { window.removeEventListener('unhandledrejection', unhandled); } }); + + // -- #3297 — auth gating before dispatch (TAURI-RUST-8WY / 8WZ) ----------- + + it('does not dispatch usage/plan RPCs while unauthenticated (#3297, TAURI-RUST-8WY/8WZ)', async () => { + // Signed out (pre-login, or after a SessionExpired clear): these RPCs + // require a backend session, so dispatching them is a guaranteed 401 at + // the backend — the flood this fix removes. The hook must skip the fetch + // entirely rather than round-trip to a doomed call. + mockAuthState.isAuthenticated = false; + const { useUsageState } = await import('./useUsageState'); + mockGetCurrentPlan.mockRejectedValue(new Error('plan must not be fetched while signed out')); + mockGetTeamUsage.mockRejectedValue(new Error('usage must not be fetched while signed out')); + mockLoadAISettings.mockRejectedValue(new Error('settings must not load while signed out')); + + const { result } = renderHook(() => useUsageState()); + // Let any (incorrectly-scheduled) async fetch microtasks flush. + await Promise.resolve(); + await Promise.resolve(); + + expect(mockLoadAISettings).not.toHaveBeenCalled(); + expect(mockGetTeamUsage).not.toHaveBeenCalled(); + expect(mockGetCurrentPlan).not.toHaveBeenCalled(); + expect(result.current.teamUsage).toBeNull(); + expect(result.current.currentPlan).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); }); diff --git a/app/src/hooks/useUsageState.ts b/app/src/hooks/useUsageState.ts index 1c3e6fdb5..9bcd8f23f 100644 --- a/app/src/hooks/useUsageState.ts +++ b/app/src/hooks/useUsageState.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; +import { useCoreState } from '../providers/CoreStateProvider'; import { type AISettings, ALL_WORKLOADS, @@ -114,6 +115,8 @@ async function fetchUsageData(): Promise<{ } export function useUsageState(): UsageState { + const { snapshot } = useCoreState(); + const isAuthenticated = snapshot.auth.isAuthenticated; const [teamUsage, setTeamUsage] = useState(null); const [currentPlan, setCurrentPlan] = useState(null); const [aiSettings, setAiSettings] = useState(null); @@ -128,6 +131,23 @@ export function useUsageState(): UsageState { useEffect(() => subscribeUsageRefresh(refresh), [refresh]); useEffect(() => { + // Gate on auth BEFORE dispatching: `team_get_usage` / `billing_get_current_plan` + // require a backend session, so polling them while signed out (pre-login, or + // after a `SessionExpired` clear) is a guaranteed 401 — the Sentry + // TAURI-RUST-8WY (`/teams/me/usage`) / 8WZ (`/payments/stripe/currentPlan`) + // flood (#3297). When unauthenticated, skip the fetch and drop any stale view + // instead of round-tripping to a doomed call. The core-side + // `require_live_session_token` precheck covers the expired-but-still-stored + // window (token present so `isAuthenticated` is still true) without a network + // call; this gate covers the absent-token windows. + if (!isAuthenticated) { + _cache = null; + setTeamUsage(null); + setCurrentPlan(null); + setAiSettings(null); + setIsLoading(false); + return; + } let cancelled = false; setIsLoading(true); fetchUsageData() @@ -151,7 +171,7 @@ export function useUsageState(): UsageState { return () => { cancelled = true; }; - }, [fetchCount]); + }, [fetchCount, isAuthenticated]); const currentTier: PlanTier = currentPlan?.plan ?? 'FREE'; const isFreeTier = currentTier === 'FREE'; diff --git a/src/api/jwt.rs b/src/api/jwt.rs index 30b8f0374..cc421df1c 100644 --- a/src/api/jwt.rs +++ b/src/api/jwt.rs @@ -1,5 +1,8 @@ //! Session JWT load and `Authorization` helpers for the TinyHumans API. +use base64::Engine; +use chrono::{DateTime, Utc}; + pub use crate::openhuman::credentials::session_support::get_session_token; pub use crate::openhuman::credentials::{APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME}; @@ -8,6 +11,36 @@ pub fn bearer_authorization_value(token: &str) -> String { format!("Bearer {}", token.trim()) } +/// Best-effort decode of a JWT's `exp` (expiry) claim into a UTC timestamp. +/// +/// The backend app-session token is a JWT but is stored bare — the client +/// historically recorded `expires_at: None` and so blindly sent requests with a +/// token it could have known was dead, generating doomed 401s (Sentry +/// TAURI-RUST-8WY `/teams/me/usage`, 8WZ `/payments/stripe/currentPlan`; #3297). +/// Decoding `exp` at store time lets `require_live_session_token` reject an +/// expired token locally instead of round-tripping to a guaranteed 401. +/// +/// This does NOT verify the signature — the client only needs to *read* `exp`; +/// the backend stays the authority on validity (a token revoked before its `exp` +/// still 401s, caught by the `flatten_authed_error` net). Returns `None` for any +/// non-JWT / malformed / `exp`-less token, in which case expiry tracking +/// degrades to the previous behaviour (no local precheck). +pub fn decode_jwt_exp(token: &str) -> Option> { + // JWT = header.payload.signature (base64url, no padding). Only the payload + // segment is needed. + let payload_b64 = token.trim().split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64)) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + // `exp` is a NumericDate (seconds since epoch); accept int or float shapes. + let exp = claims + .get("exp") + .and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))?; + DateTime::::from_timestamp(exp, 0) +} + #[cfg(test)] mod tests { use super::*; @@ -35,4 +68,43 @@ mod tests { "Bearer token with spaces" ); } + + fn jwt_with_payload(payload_json: &str) -> String { + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json); + // header + signature are irrelevant to `decode_jwt_exp` (no verification). + format!("eyJhbGciOiJIUzI1NiJ9.{payload}.sig") + } + + #[test] + fn decode_jwt_exp_reads_integer_exp() { + let token = jwt_with_payload(r#"{"sub":"u1","exp":1700000000}"#); + assert_eq!( + decode_jwt_exp(&token), + DateTime::::from_timestamp(1_700_000_000, 0) + ); + } + + #[test] + fn decode_jwt_exp_reads_float_exp() { + let token = jwt_with_payload(r#"{"exp":1700000000.0}"#); + assert_eq!( + decode_jwt_exp(&token), + DateTime::::from_timestamp(1_700_000_000, 0) + ); + } + + #[test] + fn decode_jwt_exp_none_when_exp_absent() { + let token = jwt_with_payload(r#"{"sub":"u1"}"#); + assert_eq!(decode_jwt_exp(&token), None); + } + + #[test] + fn decode_jwt_exp_none_for_non_jwt_or_garbage() { + assert_eq!(decode_jwt_exp("not-a-jwt"), None); + assert_eq!(decode_jwt_exp(""), None); + assert_eq!(decode_jwt_exp("a.b"), None); // payload "b" isn't valid base64 JSON + // local offline session sentinel (not a JWT) must not panic / must be None + assert_eq!(decode_jwt_exp("local-session-xyz"), None); + } } diff --git a/src/api/mod.rs b/src/api/mod.rs index 3ebec59c2..f9baeb981 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -18,7 +18,8 @@ pub use config::{ }; pub use jwt::{bearer_authorization_value, get_session_token}; pub use rest::{ - decrypt_handoff_blob, user_id_from_auth_me_payload, user_id_from_profile_payload, - BackendOAuthClient, ConnectResponse, IntegrationSummary, IntegrationTokensHandoff, + decrypt_handoff_blob, flatten_authed_error, user_id_from_auth_me_payload, + user_id_from_profile_payload, BackendApiError, BackendOAuthClient, ConnectResponse, + IntegrationSummary, IntegrationTokensHandoff, }; pub use socket::websocket_url; diff --git a/src/api/rest.rs b/src/api/rest.rs index e1f4a06ac..649f25b4b 100644 --- a/src/api/rest.rs +++ b/src/api/rest.rs @@ -42,6 +42,35 @@ pub enum BackendApiError { }, } +/// Flatten an `authed_json` error onto the JSON-RPC `String` channel. +/// +/// `BackendApiError::Unauthorized` is an expected backend session-lapse 401 +/// (token expired / revoked / rotated server-side), not a code bug — see the +/// variant docs above. Callers used to flatten it with `format!("{e:#}")` / +/// `e.to_string()`, producing `"backend rejected session token on {method} +/// {path}"`, which matches none of the JSON-RPC session-expiry classifiers +/// (`is_session_expired_error`, `is_session_expired_message`, the `before_send` +/// net), so every lapsed-session 401 leaked to Sentry — TAURI-RUST-8WY +/// (`/teams/me/usage`), TAURI-RUST-8WZ (`/payments/stripe/currentPlan`), and the +/// rest of the authed-endpoint family (#3297). +/// +/// Mapping `Unauthorized` onto the existing `SESSION_EXPIRED` sentinel makes the +/// dispatcher (`core/jsonrpc.rs`) classify it as session expiry: it skips the +/// Sentry report AND publishes `DomainEvent::SessionExpired` so the auth domain +/// drives re-sign-in. This keys off the typed downcast — not the Display +/// wording — so it stays correct if the `#[error(...)]` text changes, consistent +/// with #2959's removal of brittle string-based suppression. Every other error +/// (including `MessageNotFound`) keeps its full `{e:#}` chain so genuine +/// failures still reach Sentry. +pub fn flatten_authed_error(err: anyhow::Error) -> String { + match err.downcast_ref::() { + Some(BackendApiError::Unauthorized { method, path }) => { + format!("SESSION_EXPIRED: backend rejected session token on {method} {path}") + } + _ => format!("{err:#}"), + } +} + /// Extract `(provider, message_id)` from a backend channel path of the /// shape `…/channels//messages/`. Returns `None` for paths /// that do not contain this four-segment subsequence. diff --git a/src/api/rest_tests.rs b/src/api/rest_tests.rs index ff11fc341..9f13821c9 100644 --- a/src/api/rest_tests.rs +++ b/src/api/rest_tests.rs @@ -1,6 +1,6 @@ use super::{ - key_bytes_from_string, parse_message_path, sanitize_client_version, BackendApiError, - BackendOAuthClient, + flatten_authed_error, key_bytes_from_string, parse_message_path, sanitize_client_version, + BackendApiError, BackendOAuthClient, }; use axum::extract::State; use axum::http::HeaderMap; @@ -418,6 +418,68 @@ async fn authed_json_surfaces_unauthorized_on_401() { assert_eq!(path, "/referral/stats"); } +#[test] +fn flatten_authed_error_maps_unauthorized_to_session_expired_sentinel() { + // #3297: the typed `Unauthorized` (expected session-lapse 401) must flatten + // onto a string that the JSON-RPC session-expiry classifiers recognise, so + // it is suppressed from Sentry (TAURI-RUST-8WY / 8WZ) instead of leaking. + let err = anyhow::Error::new(BackendApiError::Unauthorized { + method: "GET".to_string(), + path: "/teams/me/usage".to_string(), + }); + let flat = flatten_authed_error(err); + + // Carries the SESSION_EXPIRED sentinel + preserves method/path for logs. + assert!( + flat.contains("SESSION_EXPIRED"), + "expected sentinel, got: {flat}" + ); + assert!(flat.contains("GET"), "method preserved: {flat}"); + assert!(flat.contains("/teams/me/usage"), "path preserved: {flat}"); + + // Contract cross-check: the flattened string MUST classify as session + // expiry. This couples the mapping to the actual classifier — if either the + // sentinel or the classifier drifts, this fails instead of silently leaking. + assert!( + crate::core::observability::is_session_expired_message(&flat), + "flattened Unauthorized must classify as session expiry: {flat}" + ); +} + +#[test] +fn flatten_authed_error_preserves_non_unauthorized_chain() { + // A non-Unauthorized failure (e.g. a transient network/timeout error) keeps + // its full `{e:#}` anyhow chain and must NOT be demoted to session expiry — + // genuine failures still reach Sentry. + let err = anyhow::anyhow!("connect timeout").context("backend request GET /teams/me/usage"); + let flat = flatten_authed_error(err); + + assert!(!flat.contains("SESSION_EXPIRED"), "must not map: {flat}"); + assert!(flat.contains("connect timeout"), "cause preserved: {flat}"); + assert!( + !crate::core::observability::is_session_expired_message(&flat), + "non-auth error must NOT classify as session expiry: {flat}" + ); +} + +#[test] +fn flatten_authed_error_does_not_swallow_message_not_found() { + // `MessageNotFound` is a different expected state handled by its own callers + // (channel streaming/delete paths downcast it); it must not be collapsed + // into the session-expiry sentinel here. + let err = anyhow::Error::new(BackendApiError::MessageNotFound { + provider: "telegram".to_string(), + message_id: "1103".to_string(), + }); + let flat = flatten_authed_error(err); + + assert!(!flat.contains("SESSION_EXPIRED"), "must not map: {flat}"); + assert!( + flat.contains("message not found"), + "display preserved: {flat}" + ); +} + #[tokio::test] async fn authed_json_403_is_not_demoted_to_unauthorized() { // 403 (Forbidden) is a genuine authorization/permission problem — the diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index 97b7e8d21..be17c546e 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -591,6 +591,26 @@ fn is_session_expired_error_matches_backend_path_401() { )); } +#[test] +fn is_session_expired_error_matches_flattened_backend_unauthorized() { + // #3297: after #2781 the backend 401 is a typed `BackendApiError::Unauthorized` + // that team/billing ops flatten via `api::flatten_authed_error`. The dispatcher + // classifier MUST recognise that flattened string as session expiry, so the + // 401 is suppressed from Sentry (TAURI-RUST-8WY on `/teams/me/usage`, + // TAURI-RUST-8WZ on `/payments/stripe/currentPlan`) AND triggers the + // `SessionExpired` publish. End-to-end: build the typed error → flatten → classify. + let flat = crate::api::flatten_authed_error(anyhow::Error::new( + crate::api::BackendApiError::Unauthorized { + method: "GET".to_string(), + path: "/teams/me/usage".to_string(), + }, + )); + assert!( + is_session_expired_error(&flat), + "flattened backend Unauthorized must classify as session expiry: {flat}" + ); +} + #[test] fn is_session_expired_error_does_not_match_generic_401_unauthorized() { // Generic 401+unauthorized strings without HTTP-method prefix must NOT match. diff --git a/src/openhuman/billing/ops.rs b/src/openhuman/billing/ops.rs index 36d443547..8e163a28e 100644 --- a/src/openhuman/billing/ops.rs +++ b/src/openhuman/billing/ops.rs @@ -14,22 +14,15 @@ use serde::Serialize; use serde_json::{json, Value}; use crate::api::config::effective_backend_api_url; -use crate::api::jwt::get_session_token; use crate::api::BackendOAuthClient; use crate::openhuman::config::Config; use crate::rpc::RpcOutcome; +/// Canonical authed-session guard. Delegates to `require_live_session_token`, +/// which rejects an expired token locally (publishing `SessionExpired`) instead +/// of firing a doomed backend 401 — see #3297 / `session_support`. fn require_token(config: &Config) -> Result { - get_session_token(config)? - .and_then(|v| { - let t = v.trim().to_string(); - if t.is_empty() { - None - } else { - Some(t) - } - }) - .ok_or_else(|| "no backend session token; run auth_store_session first".to_string()) + crate::openhuman::credentials::session_support::require_live_session_token(config) } async fn get_authed_value( @@ -41,10 +34,15 @@ async fn get_authed_value( let token = require_token(config)?; let api_url = effective_backend_api_url(&config.api_url); let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?; + // `flatten_authed_error` maps the typed `BackendApiError::Unauthorized` + // (expected session-lapse 401) onto the `SESSION_EXPIRED` sentinel so the + // JSON-RPC layer classifies it as session expiry and skips Sentry (#3297, + // TAURI-RUST-8WZ on `/payments/stripe/currentPlan`); every other error keeps + // its full `{e:#}` anyhow chain. client .authed_json(&token, method, path, body) .await - .map_err(|e| e.to_string()) + .map_err(crate::api::flatten_authed_error) } pub async fn get_current_plan(config: &Config) -> Result, String> { diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index 32a7845c8..ae8604287 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -177,6 +177,33 @@ pub async fn store_session( }; metadata.insert("user_json".to_string(), user_for_store.to_string()); + // Record the JWT `exp` so `require_live_session_token` can reject an expired + // token locally instead of firing a doomed backend 401 (#3297 RCA — the + // TAURI-RUST-8WY/8WZ flood). Local offline sessions aren't JWTs and carry no + // `exp`; `decode_jwt_exp` returns None for them and the key is simply omitted + // (presence-only check + the `flatten_authed_error` 401 net still apply). + if !local_session { + match crate::api::jwt::decode_jwt_exp(trimmed_token) { + Some(exp) => { + metadata.insert( + crate::openhuman::credentials::session_support::SESSION_EXPIRES_AT_META + .to_string(), + exp.to_rfc3339(), + ); + tracing::info!( + domain = "credentials", + operation = "store_session", + "[credentials] recorded app-session expiry exp={exp} for local precheck" + ); + } + None => tracing::debug!( + domain = "credentials", + operation = "store_session", + "[credentials] app-session token has no decodable `exp`; local expiry precheck disabled (falls back to 401 net)" + ), + } + } + // Determine user_id so we can scope the openhuman directory to this user. let resolved_user_id = metadata.get("user_id").cloned(); diff --git a/src/openhuman/credentials/session_support.rs b/src/openhuman/credentials/session_support.rs index b0c4fb63a..64ccfbd11 100644 --- a/src/openhuman/credentials/session_support.rs +++ b/src/openhuman/credentials/session_support.rs @@ -142,6 +142,100 @@ pub fn get_session_token(config: &Config) -> Result, String> { Ok(session_token_from_profile(profile.as_ref())) } +/// Metadata key under which the app-session profile records the decoded JWT +/// `exp` (RFC3339). Written at `store_session` time (`ops::store_session`). +/// Absent for local offline sessions and `exp`-less tokens. +pub const SESSION_EXPIRES_AT_META: &str = "session_expires_at"; + +/// Treat a token as expired this many seconds *before* its real `exp`, so an +/// in-flight request can't race the boundary into a backend 401. +const SESSION_EXPIRY_SKEW_SECS: i64 = 30; + +fn session_expires_at_from_profile( + profile: Option<&AuthProfile>, +) -> Option> { + profile? + .metadata + .get(SESSION_EXPIRES_AT_META) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)) +} + +/// Liveness verdict for the stored app-session token. Pure + `now`-injected so +/// the expiry decision is unit-testable without a credential store. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SessionTokenCheck { + /// No token stored (signed out / never authed). + Absent, + /// Token present but its recorded `exp` is in the past (− skew). + Expired, + /// Token present and within its validity window (or no recorded expiry). + Live(String), +} + +pub(crate) fn classify_session_token( + profile: Option<&AuthProfile>, + now: chrono::DateTime, +) -> SessionTokenCheck { + let Some(token) = session_token_from_profile(profile) else { + return SessionTokenCheck::Absent; + }; + if let Some(exp) = session_expires_at_from_profile(profile) { + if now + chrono::Duration::seconds(SESSION_EXPIRY_SKEW_SECS) >= exp { + return SessionTokenCheck::Expired; + } + } + SessionTokenCheck::Live(token) +} + +/// Canonical guard for every backend `authed_json` caller (team, billing, and +/// any future authed RPC op). Returns the live app-session token, or an error +/// the JSON-RPC layer classifies as session-expiry — **without sending a doomed +/// request**. +/// +/// - absent / empty token → "no backend session token" (local, no network). +/// - token whose recorded `exp` is past (− skew) → publishes `SessionExpired` +/// **once** (so the credentials subscriber clears state and the UI re-auths, +/// exactly as on a real network 401) and returns the `SESSION_EXPIRED` +/// sentinel. The doomed 401 is never sent — this is the #3297 RCA that stops +/// the TAURI-RUST-8WY (`/teams/me/usage`) / 8WZ (`/payments/stripe/currentPlan`) +/// flood at its source instead of demoting it after the fact. +/// - token with no recorded expiry (local offline session / `exp`-less JWT) → +/// presence-only check; the `flatten_authed_error` 401 net still covers a +/// server-side revocation that precedes the recorded `exp`. +pub fn require_live_session_token(config: &Config) -> Result { + let profile = load_app_session_profile(config)?; + match classify_session_token(profile.as_ref(), chrono::Utc::now()) { + SessionTokenCheck::Live(token) => Ok(token), + SessionTokenCheck::Absent => { + Err("no backend session token; run auth_store_session first".to_string()) + } + SessionTokenCheck::Expired => { + // Dedupe the publish via the scheduler gate so N parallel authed + // callers in one tick don't emit N SessionExpired events. + if !crate::openhuman::scheduler_gate::is_signed_out() { + tracing::info!( + domain = "credentials", + operation = "require_live_session_token", + "[credentials] app-session token expired locally — publishing SessionExpired before any backend call" + ); + crate::core::event_bus::publish_global( + crate::core::event_bus::DomainEvent::SessionExpired { + source: "credentials.local_expiry_precheck".to_string(), + reason: + "backend session token expired locally — re-authentication required" + .to_string(), + }, + ); + } + Err( + "SESSION_EXPIRED: backend session token expired locally — re-authentication required" + .to_string(), + ) + } + } +} + /// Load the `app-session` profile once. Callers that need both the /// session-state view (`session_state_from_profile`) AND the raw token /// (`session_token_from_profile`) should call this once and pass the @@ -451,4 +545,77 @@ mod tests { assert!(state.is_authenticated); assert!(state.profile_id.is_some()); } + + // ── classify_session_token (local expiry precheck, #3297) ────────── + + fn token_profile_with_expiry(token: Option<&str>, expires_at: Option<&str>) -> AuthProfile { + let mut p = profile_fixture(AuthProfileKind::Token, token); + match expires_at { + Some(rfc3339) => { + p.metadata + .insert(SESSION_EXPIRES_AT_META.to_string(), rfc3339.to_string()); + } + None => { + p.metadata.remove(SESSION_EXPIRES_AT_META); + } + } + p + } + + #[test] + fn classify_absent_when_no_profile_or_empty_token() { + let now = Utc::now(); + assert_eq!(classify_session_token(None, now), SessionTokenCheck::Absent); + let p = token_profile_with_expiry(Some(" "), None); + assert_eq!( + classify_session_token(Some(&p), now), + SessionTokenCheck::Absent + ); + } + + #[test] + fn classify_live_when_no_recorded_expiry() { + // exp-less / local sessions fall through to presence-only (401 net covers revocation). + let now = Utc::now(); + let p = token_profile_with_expiry(Some("jwt-token"), None); + assert_eq!( + classify_session_token(Some(&p), now), + SessionTokenCheck::Live("jwt-token".to_string()) + ); + } + + #[test] + fn classify_live_when_expiry_in_future() { + let now = Utc::now(); + let future = (now + chrono::Duration::hours(1)).to_rfc3339(); + let p = token_profile_with_expiry(Some("jwt-token"), Some(&future)); + assert_eq!( + classify_session_token(Some(&p), now), + SessionTokenCheck::Live("jwt-token".to_string()) + ); + } + + #[test] + fn classify_expired_when_exp_in_past() { + let now = Utc::now(); + let past = (now - chrono::Duration::hours(1)).to_rfc3339(); + let p = token_profile_with_expiry(Some("jwt-token"), Some(&past)); + assert_eq!( + classify_session_token(Some(&p), now), + SessionTokenCheck::Expired + ); + } + + #[test] + fn classify_expired_within_skew_window() { + // exp is technically in the future but inside the 30s skew → treat as expired + // so an in-flight request can't race the boundary into a 401. + let now = Utc::now(); + let soon = (now + chrono::Duration::seconds(10)).to_rfc3339(); + let p = token_profile_with_expiry(Some("jwt-token"), Some(&soon)); + assert_eq!( + classify_session_token(Some(&p), now), + SessionTokenCheck::Expired + ); + } } diff --git a/src/openhuman/team/ops.rs b/src/openhuman/team/ops.rs index 62084488a..2faf8ee96 100644 --- a/src/openhuman/team/ops.rs +++ b/src/openhuman/team/ops.rs @@ -14,22 +14,15 @@ use serde::Serialize; use serde_json::{json, Value}; use crate::api::config::effective_backend_api_url; -use crate::api::jwt::get_session_token; use crate::api::BackendOAuthClient; use crate::openhuman::config::Config; use crate::rpc::RpcOutcome; +/// Canonical authed-session guard. Delegates to `require_live_session_token`, +/// which rejects an expired token locally (publishing `SessionExpired`) instead +/// of firing a doomed backend 401 — see #3297 / `session_support`. fn require_token(config: &Config) -> Result { - get_session_token(config)? - .and_then(|v| { - let t = v.trim().to_string(); - if t.is_empty() { - None - } else { - Some(t) - } - }) - .ok_or_else(|| "no backend session token; run auth_store_session first".to_string()) + crate::openhuman::credentials::session_support::require_live_session_token(config) } fn normalize_id(input: &str, field: &str) -> Result { @@ -64,21 +57,24 @@ async fn get_authed_value( let token = require_token(config)?; let api_url = effective_backend_api_url(&config.api_url); let client = BackendOAuthClient::new(&api_url).map_err(|e| format!("{e:#}"))?; - // `{e:#}` renders the full anyhow chain. `authed_json` wraps the - // underlying reqwest error with `.context(format!("backend request {} {}", …))` - // (`api/rest.rs::authed_json`), so plain `e.to_string()` only emits - // the outer "backend request GET /teams" label and drops the cause - // (connect timeout, DNS failure, TLS handshake, non-2xx status, …) - // before the JSON-RPC layer reports it to Sentry. OPENHUMAN-TAURI-AD - // is the canonical instance: 2 events on `0.53.35` from a Russia - // user, all with the truncated label and elapsed_ms=49 — far too - // short for a real timeout, so the underlying cause is the only - // signal worth surfacing. Same failure mode the `report_error` - // doc-string in `core/observability.rs` calls out (TAURI-B2). + // `flatten_authed_error` maps the typed `BackendApiError::Unauthorized` + // (expected session-lapse 401) onto the `SESSION_EXPIRED` sentinel so the + // JSON-RPC layer classifies it as session expiry and skips Sentry (#3297, + // TAURI-RUST-8WY on `/teams/me/usage`); every other error keeps its full + // `{e:#}` anyhow chain. `authed_json` wraps the underlying reqwest error + // with `.context(format!("backend request {} {}", …))` + // (`api/rest.rs::authed_json`), so `{e:#}` (not `e.to_string()`) is required + // to surface the cause (connect timeout, DNS failure, TLS handshake, non-2xx + // status, …) before the JSON-RPC layer reports it to Sentry. + // OPENHUMAN-TAURI-AD is the canonical instance: 2 events on `0.53.35` from a + // Russia user, all with the truncated label and elapsed_ms=49 — far too + // short for a real timeout, so the underlying cause is the only signal worth + // surfacing. Same failure mode the `report_error` doc-string in + // `core/observability.rs` calls out (TAURI-B2). client .authed_json(&token, method, path, body) .await - .map_err(|e| format!("{e:#}")) + .map_err(crate::api::flatten_authed_error) } pub async fn get_usage(config: &Config) -> Result, String> {