fix(credentials): reject expired session locally + gate usage polling — RCA for backend 401 flood (#3297) (#3384)

This commit is contained in:
oxoxDev
2026-06-04 13:37:06 -04:00
committed by GitHub
parent f3e70e67ab
commit e7e26efb3e
11 changed files with 468 additions and 40 deletions
+36
View File
@@ -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);
});
});
+21 -1
View File
@@ -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<TeamUsage | null>(null);
const [currentPlan, setCurrentPlan] = useState<CurrentPlanData | null>(null);
const [aiSettings, setAiSettings] = useState<AISettings | null>(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';
+72
View File
@@ -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<DateTime<Utc>> {
// 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::<Utc>::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::<Utc>::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::<Utc>::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);
}
}
+3 -2
View File
@@ -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;
+29
View File
@@ -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::<BackendApiError>() {
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/<provider>/messages/<id>`. Returns `None` for paths
/// that do not contain this four-segment subsequence.
+64 -2
View File
@@ -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
+20
View File
@@ -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.
+10 -12
View File
@@ -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<String, String> {
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<RpcOutcome<Value>, String> {
+27
View File
@@ -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();
@@ -142,6 +142,100 @@ pub fn get_session_token(config: &Config) -> Result<Option<String>, 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<chrono::DateTime<chrono::Utc>> {
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<chrono::Utc>,
) -> 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<String, String> {
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
);
}
}
+19 -23
View File
@@ -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<String, String> {
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<String, String> {
@@ -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<RpcOutcome<Value>, String> {