Skip to content

Commit 8b4350e

Browse files
authored
fix(auth): stop 401 cascade after session expiry (OPENHUMAN-TAURI-1T) (tinyhumansai#1516)
1 parent ef556f8 commit 8b4350e

16 files changed

Lines changed: 548 additions & 33 deletions

File tree

app/src/providers/CoreStateProvider.tsx

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -570,37 +570,62 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
570570
});
571571
}, [commitState, refresh]);
572572

573+
// Listen for two flavours of session expiry, both routed through the
574+
// same debounced `clearSession`:
575+
//
576+
// 1. `core-rpc-auth-expired` — emitted by `coreRpcClient` when an
577+
// individual RPC call returns 401 (usage pill, upsell banner,
578+
// threads poll, …). Multiple parallel chains can fire it in the
579+
// same frame after a token expires; the 10s debounce coalesces
580+
// them so `clearSession` only runs once.
581+
// 2. `openhuman:session-expired` — emitted by `socketService` when
582+
// the core pushes `auth:session_expired` over Socket.IO (the
583+
// OpenHuman backend provider's `api_error` published
584+
// `DomainEvent::SessionExpired`, or `jsonrpc::invoke_method`
585+
// detected a 401 on a server-side method call). Without this, the
586+
// UI keeps showing a logged-in shell until the next refresh()
587+
// discovers the missing token — confusing, and a security smell
588+
// on shared devices.
589+
//
590+
// Depends on `clearSession` so the listener always closes over the
591+
// latest closure; `clearSession`'s own deps are stable `useCallback`s,
592+
// so re-registers are rare.
573593
useEffect(() => {
574-
const onAuthExpired = (event: Event) => {
575-
const customEvent = event as CustomEvent<{ method?: string; source?: string }>;
594+
const runReauth = (method: string, source: string) => {
576595
const now = Date.now();
577-
// Multiple parallel RPC chains (usage pill + upsell banner + threads
578-
// poll) can each surface a 401 within the same frame after a token
579-
// expires. Debounce so clearSession only runs once per real auth-loss
580-
// event — the 10s window covers any straggler chains without blocking
581-
// a legitimate re-login that immediately fails again.
582596
if (now - lastReauthAtRef.current < 10_000) {
583-
log(
584-
'auth-expired debounced (method=%s source=%s)',
585-
customEvent.detail?.method ?? 'unknown',
586-
customEvent.detail?.source ?? 'unknown'
587-
);
597+
log('auth-expired debounced (method=%s source=%s)', method, source);
588598
return;
589599
}
590600
lastReauthAtRef.current = now;
591-
log(
592-
'auth-expired: clearing session (method=%s source=%s)',
593-
customEvent.detail?.method ?? 'unknown',
594-
customEvent.detail?.source ?? 'unknown'
595-
);
601+
log('auth-expired: clearing session (method=%s source=%s)', method, source);
596602
void clearSession().catch(err => {
597603
log('clearSession failed after auth-expired: %O', sanitizeError(err));
598604
});
599605
};
600606

601-
window.addEventListener('core-rpc-auth-expired', onAuthExpired as EventListener);
607+
const onRpcExpired = (event: Event) => {
608+
const detail = (event as CustomEvent<{ method?: string; source?: string }>).detail;
609+
runReauth(detail?.method ?? 'unknown', detail?.source ?? 'core-rpc-auth-expired');
610+
};
611+
612+
const onSocketExpired = (event: Event) => {
613+
const source =
614+
event instanceof CustomEvent &&
615+
event.detail &&
616+
typeof event.detail === 'object' &&
617+
'source' in event.detail &&
618+
typeof (event.detail as { source?: unknown }).source === 'string'
619+
? (event.detail as { source: string }).source
620+
: 'unknown';
621+
runReauth('socket.session_expired', source);
622+
};
623+
624+
window.addEventListener('core-rpc-auth-expired', onRpcExpired as EventListener);
625+
window.addEventListener('openhuman:session-expired', onSocketExpired as EventListener);
602626
return () => {
603-
window.removeEventListener('core-rpc-auth-expired', onAuthExpired as EventListener);
627+
window.removeEventListener('core-rpc-auth-expired', onRpcExpired as EventListener);
628+
window.removeEventListener('openhuman:session-expired', onSocketExpired as EventListener);
604629
};
605630
}, [clearSession]);
606631

app/src/services/socketService.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,25 @@ class SocketService {
246246
this.socket.on('channel:connection-updated', handleChannelConnectionUpdated);
247247
this.socket.on('channel_connection_updated', handleChannelConnectionUpdated);
248248

249+
// Core-side session expiry (401 from the OpenHuman backend or jsonrpc).
250+
// The server has already published SessionExpired on its event bus,
251+
// the credentials subscriber has cleared the JWT, and the scheduler
252+
// gate is flipped to signed-out. All the UI needs to do is mirror
253+
// that locally and route to onboarding. CoreStateProvider listens
254+
// for the window event below and calls its own `clearSession`.
255+
const handleSessionExpired = (data: unknown) => {
256+
const source =
257+
(data && typeof data === 'object' && 'source' in data && typeof data.source === 'string'
258+
? data.source
259+
: undefined) ?? 'unknown';
260+
socketLog('Session expired notification received', { source });
261+
if (typeof window !== 'undefined') {
262+
window.dispatchEvent(new CustomEvent('openhuman:session-expired', { detail: { source } }));
263+
}
264+
};
265+
this.socket.on('auth:session_expired', handleSessionExpired);
266+
this.socket.on('auth_session_expired', handleSessionExpired);
267+
249268
this.socket.on('channel:managed-dm-verified', data => {
250269
const obj = data as Record<string, unknown> | null;
251270
if (!obj || typeof obj !== 'object') return;

src/core/event_bus/bus.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@ impl EventBus {
102102
Self { tx }
103103
}
104104

105+
/// Get a raw broadcast receiver. Used by bridges that need to drive
106+
/// their own filtering loop (e.g. the Socket.IO `auth:session_expired`
107+
/// forwarder) instead of going through the [`EventHandler`] trait.
108+
/// Prefer [`Self::subscribe`] for normal subscribers — it provides
109+
/// domain filtering, panic isolation, and lifetime via
110+
/// [`SubscriptionHandle`].
111+
pub fn raw_receiver(&self) -> broadcast::Receiver<DomainEvent> {
112+
self.tx.subscribe()
113+
}
114+
105115
/// Publish an event to all active subscribers.
106116
///
107117
/// The event is cloned and sent to each subscriber's receiving end.
@@ -242,6 +252,24 @@ mod tests {
242252
});
243253
}
244254

255+
#[tokio::test]
256+
async fn raw_receiver_observes_published_events() {
257+
// raw_receiver is the escape hatch used by the Socket.IO bridge
258+
// (auth:session_expired forwarder). Confirm it sees published
259+
// events the same way a trait-based subscriber would.
260+
let bus = EventBus::create(16);
261+
let mut rx = bus.raw_receiver();
262+
bus.publish(DomainEvent::SessionExpired {
263+
source: "test".into(),
264+
reason: "401".into(),
265+
});
266+
let event = tokio::time::timeout(Duration::from_millis(200), rx.recv())
267+
.await
268+
.expect("recv should not hang")
269+
.expect("recv should not error");
270+
assert!(matches!(event, DomainEvent::SessionExpired { .. }));
271+
}
272+
245273
#[tokio::test]
246274
async fn single_subscriber_receives_event() {
247275
let bus = EventBus::create(16);

src/core/event_bus/events.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,19 @@ pub enum DomainEvent {
421421
},
422422
/// A component restart was observed.
423423
HealthRestarted { component: String },
424+
425+
// ── Auth ────────────────────────────────────────────────────────────
426+
/// The local app session is no longer valid — typically detected when
427+
/// the backend returns 401 to an LLM inference call or a JSON-RPC
428+
/// method. Subscribers tear down the session and pause background
429+
/// LLM-bound work until the user signs back in.
430+
///
431+
/// `source` is a short slug (e.g. `"llm_provider.openhuman_backend"`,
432+
/// `"jsonrpc.invoke_method"`) so subscribers and logs can attribute
433+
/// the trigger. `reason` is the sanitized error message that caused
434+
/// detection (already redacted by the call site) — surfaced to logs,
435+
/// never to Sentry or the UI verbatim.
436+
SessionExpired { source: String, reason: String },
424437
}
425438

426439
impl DomainEvent {
@@ -490,6 +503,8 @@ impl DomainEvent {
490503
| Self::SystemShutdownRequested { .. }
491504
| Self::HealthChanged { .. }
492505
| Self::HealthRestarted { .. } => "system",
506+
507+
Self::SessionExpired { .. } => "auth",
493508
}
494509
}
495510
}

src/core/event_bus/events_tests.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,14 @@ fn all_variants_have_correct_domain() {
438438
},
439439
"learning",
440440
),
441+
// Auth
442+
(
443+
DomainEvent::SessionExpired {
444+
source: "test".into(),
445+
reason: "401".into(),
446+
},
447+
"auth",
448+
),
441449
];
442450

443451
for (event, expected_domain) in cases {

src/core/jsonrpc.rs

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,28 @@ pub async fn rpc_handler(State(state): State<AppState>, Json(req): Json<RpcReque
100100
pub async fn invoke_method(state: AppState, method: &str, params: Value) -> Result<Value, String> {
101101
let result = invoke_method_inner(state, method, params).await;
102102

103-
// Session auto-cleanup: If the backend says we're unauthorized,
104-
// we should reflect that locally by clearing the stored token.
103+
// Session auto-cleanup: if the backend says we're unauthorized, publish
104+
// a `SessionExpired` event. The credentials subscriber clears the stored
105+
// token, flips the scheduler-gate signed-out override so background
106+
// workers stand down, and (eventually) pushes a sign-out to the UI.
107+
// Centralising via the event bus means 401 detection from any path
108+
// (this one, `llm_provider.api_error`, …) gets the same teardown.
105109
if let Err(ref msg) = result {
106110
if is_session_expired_error(msg) {
107111
log::warn!(
108-
"[jsonrpc] backend returned 401 for method '{}' — clearing stored session",
112+
"[jsonrpc] backend returned 401 for method '{}' — publishing SessionExpired",
109113
method
110114
);
111-
if let Ok(config) = crate::openhuman::config::rpc::load_config_with_timeout().await {
112-
let _ = crate::openhuman::credentials::rpc::clear_session(&config).await;
113-
}
115+
// Scrub before publishing — subscribers log `reason`, and the
116+
// upstream error string could include API keys / tokens from
117+
// pasted-through provider replies. `sanitize_api_error` runs
118+
// `scrub_secret_patterns` and truncates.
119+
crate::core::event_bus::publish_global(
120+
crate::core::event_bus::DomainEvent::SessionExpired {
121+
source: format!("jsonrpc.invoke_method:{method}"),
122+
reason: crate::openhuman::providers::ops::sanitize_api_error(msg),
123+
},
124+
);
114125
}
115126
}
116127

@@ -944,6 +955,42 @@ fn register_domain_subscribers(
944955
// (otherwise they fall back to `Policy::Normal` and miss the
945956
// initial throttle decision on battery-powered hosts).
946957
crate::openhuman::scheduler_gate::init_global(&config);
958+
959+
// Seed the scheduler-gate signed-out override from the on-disk
960+
// session. Without this, a sidecar that boots with no stored JWT
961+
// would happily spin up cron / channel loops and fire LLM requests
962+
// that all 401 immediately.
963+
match crate::api::jwt::get_session_token(&config) {
964+
Ok(Some(_)) => {
965+
crate::openhuman::scheduler_gate::set_signed_out(false);
966+
}
967+
Ok(None) => {
968+
log::info!(
969+
"[auth] no session token at startup — scheduler gate set to signed_out"
970+
);
971+
crate::openhuman::scheduler_gate::set_signed_out(true);
972+
}
973+
Err(err) => {
974+
log::warn!(
975+
"[auth] failed to read session token at startup ({err}) — assuming signed_out"
976+
);
977+
crate::openhuman::scheduler_gate::set_signed_out(true);
978+
}
979+
}
980+
981+
// Register the SessionExpired handler before any subscribers that
982+
// might publish 401-derived events, so the very first 401 is
983+
// routed through `clear_session` + the scheduler-gate override.
984+
if let Some(handle) = crate::core::event_bus::subscribe_global(Arc::new(
985+
crate::openhuman::credentials::bus::SessionExpiredSubscriber::new(),
986+
)) {
987+
std::mem::forget(handle);
988+
} else {
989+
log::warn!(
990+
"[event_bus] failed to register SessionExpired subscriber — bus not initialized"
991+
);
992+
}
993+
947994
crate::openhuman::memory::tree::jobs::start(config.clone());
948995

949996
// Restart requests go through a subscriber so every trigger path shares
@@ -970,7 +1017,7 @@ fn register_domain_subscribers(
9701017
crate::openhuman::agent::bus::register_agent_handlers();
9711018

9721019
log::info!(
973-
"[event_bus] domain subscribers registered (webhook, channel, health, conversation, composio, restart, proactive, agent)"
1020+
"[event_bus] domain subscribers registered (webhook, channel, health, conversation, composio, restart, proactive, agent, session_expired)"
9741021
);
9751022
});
9761023
}

src/core/socketio.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
333333
let io_overlay = io.clone();
334334
let io_notify = io.clone();
335335
let io_transcription = io.clone();
336+
let io_auth = io.clone();
336337

337338
// 2. Dictation hotkey events → broadcast to all connected clients.
338339
tokio::spawn(async move {
@@ -420,6 +421,69 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
420421
log::debug!("[socketio] core_notification bridge stopped");
421422
});
422423

424+
// 6. SessionExpired events → broadcast to all clients so the UI can
425+
// proactively tear down user-scoped state and route to onboarding
426+
// instead of waiting for the next poll to discover the JWT is gone.
427+
// Subscribes to the global event bus and filters for
428+
// `DomainEvent::SessionExpired`; ignores everything else.
429+
tokio::spawn(async move {
430+
// Poll until `event_bus::init_global` has run. Socket.IO bridges
431+
// spawn from `spawn_web_channel_bridge`, which on some startup
432+
// paths runs before `register_domain_subscribers` initialises
433+
// the bus. A one-shot check would silently no-op for the rest
434+
// of the process; a short polling loop with a hard cap retries
435+
// without spinning forever if init genuinely never happens
436+
// (e.g. tests that drive the socket layer in isolation).
437+
let bus = {
438+
const RETRY_INTERVAL_MS: u64 = 250;
439+
const MAX_WAIT_SECS: u64 = 30;
440+
let max_attempts = (MAX_WAIT_SECS * 1000) / RETRY_INTERVAL_MS;
441+
let mut attempts: u64 = 0;
442+
loop {
443+
if let Some(bus) = crate::core::event_bus::global() {
444+
break bus;
445+
}
446+
attempts += 1;
447+
if attempts > max_attempts {
448+
log::warn!(
449+
"[socketio] event_bus not initialised after {}s — SessionExpired bridge giving up",
450+
MAX_WAIT_SECS
451+
);
452+
return;
453+
}
454+
tokio::time::sleep(std::time::Duration::from_millis(RETRY_INTERVAL_MS)).await;
455+
}
456+
};
457+
let mut rx = bus.raw_receiver();
458+
loop {
459+
let event = match rx.recv().await {
460+
Ok(event) => event,
461+
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
462+
log::warn!(
463+
"[socketio] dropped {} event_bus events due to lag (auth bridge)",
464+
skipped
465+
);
466+
continue;
467+
}
468+
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
469+
};
470+
if let crate::core::event_bus::DomainEvent::SessionExpired { source, reason } = event {
471+
log::info!(
472+
"[socketio] broadcast auth:session_expired source={} reason_len={}",
473+
source,
474+
reason.len()
475+
);
476+
// The UI doesn't need the raw reason (already logged
477+
// server-side and we don't want auth-error strings in the
478+
// renderer console). Just send the source slug.
479+
let payload = serde_json::json!({ "source": source });
480+
let _ = io_auth.emit("auth:session_expired", &payload);
481+
let _ = io_auth.emit("auth_session_expired", &payload);
482+
}
483+
}
484+
log::debug!("[socketio] auth session_expired bridge stopped");
485+
});
486+
423487
// 5. Transcription results → broadcast to all connected clients.
424488
tokio::spawn(async move {
425489
let mut rx = crate::openhuman::voice::dictation_listener::subscribe_transcription_results();

0 commit comments

Comments
 (0)