mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(credentials): reject expired session locally + gate usage polling — RCA for backend 401 flood (#3297) (#3384)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user