1use std::sync::Arc;
35
36use serde::{Deserialize, Serialize};
37
38use crate::backend::rpc::engine::WshRpcEngine;
39
40use super::identity_auth_dirs::{caller_auth_dir, compute_and_ensure_account_dir, compute_and_ensure_bundle_dir};
41use super::identity_auth_spawn::spawn_auth_cli;
42use super::AppState;
43
44pub const COMMAND_AUTH_START: &str = "auth.start";
45pub const COMMAND_AUTH_POLL: &str = "auth.poll";
46pub const COMMAND_AUTH_SUBMIT_CALLBACK: &str = "auth.submitcallback";
47pub const COMMAND_AUTH_CANCEL: &str = "auth.cancel";
48pub const COMMAND_ENSURE_ACCOUNT_DIR: &str = "identity.ensureaccountdir";
59
60#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
61#[ts(export, export_to = "../../../frontend/types/rpc/")]
62#[serde(rename_all = "camelCase")]
63pub struct StartProviderAuthReq {
64 pub provider_id: String,
65 #[serde(default)]
70 #[ts(optional)]
71 pub into_bundle_id: Option<String>,
72 #[serde(default)]
78 pub direct_account: bool,
79 #[serde(default)]
84 pub existing_account_id: String,
85 pub cli_path: String,
90 pub auth_login_args: Vec<String>,
93 pub auth_check_args: Vec<String>,
96 #[serde(default)]
100 pub auth_env: std::collections::HashMap<String, String>,
101 #[serde(default)]
108 pub requires_tty: bool,
109}
110
111#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
112#[ts(export, export_to = "../../../frontend/types/rpc/")]
113#[serde(rename_all = "camelCase")]
114pub struct PollProviderAuthReq {
115 pub session_id: String,
116}
117
118#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
119#[ts(export, export_to = "../../../frontend/types/rpc/")]
120#[serde(rename_all = "camelCase")]
121pub struct SubmitAuthCallbackReq {
122 pub session_id: String,
123 pub callback_url: String,
126}
127
128#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
129#[ts(export, export_to = "../../../frontend/types/rpc/")]
130#[serde(rename_all = "camelCase")]
131pub struct CancelProviderAuthReq {
132 pub session_id: String,
133}
134
135#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
136#[ts(export, export_to = "../../../frontend/types/rpc/")]
137#[serde(rename_all = "camelCase")]
138pub struct AckResp {
139 pub success: bool,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 #[ts(optional)]
142 pub error: Option<String>,
143}
144
145#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
146#[ts(export, export_to = "../../../frontend/types/rpc/")]
147#[serde(rename_all = "camelCase")]
148pub struct EnsureAccountDirReq {
149 pub provider_id: String,
150 #[serde(default)]
153 pub existing_account_id: String,
154}
155
156#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
157#[ts(export, export_to = "../../../frontend/types/rpc/")]
158#[serde(rename_all = "camelCase")]
159pub struct EnsureAccountDirResp {
160 pub account_id: String,
161 #[serde(skip_serializing_if = "Option::is_none")]
165 #[ts(optional)]
166 pub dir: Option<String>,
167}
168
169pub fn register_identity_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
170 let mgr = state.auth_session_manager.clone();
171 let mstore = state.id_store.clone();
172 let identity_store = state.identity_store.clone();
173 let broker = state.broker.clone();
174 let mstore_for_ensure_dir = mstore.clone();
175 engine.register_typed(
176 COMMAND_AUTH_START,
177 move |req: StartProviderAuthReq, _ctx| {
178 let mgr = mgr.clone();
179 let mstore = mstore.clone();
180 let identity_store = identity_store.clone();
181 let broker = broker.clone();
182 async move {
183 tracing::info!(
184 provider_id = %req.provider_id,
185 cli_path = %req.cli_path,
186 into_bundle_id = ?req.into_bundle_id,
187 direct_account = req.direct_account,
188 "auth.start"
189 );
190 let mut auth_env = req.auth_env;
208 let (account_id, bundle_dir) = if req.direct_account {
209 let (account_id, dir) = compute_and_ensure_account_dir(
210 &mstore,
211 &req.existing_account_id,
212 &req.provider_id,
213 &mut auth_env,
214 );
215 (account_id, dir)
216 } else {
217 let dir = compute_and_ensure_bundle_dir(
218 req.into_bundle_id.as_deref(),
219 &req.provider_id,
220 &mut auth_env,
221 );
222 (String::new(), dir)
223 };
224 let exclusive_key = bundle_dir
228 .clone()
229 .or_else(|| caller_auth_dir(&req.provider_id, &auth_env));
230 let r = mgr.start_session(req.provider_id.clone(), exclusive_key);
231 spawn_auth_cli(
232 mgr,
233 mstore,
234 identity_store,
235 broker,
236 r.session_id.clone(),
237 req.provider_id,
238 req.into_bundle_id,
239 bundle_dir,
240 req.direct_account,
241 account_id,
242 req.cli_path,
243 req.auth_login_args,
244 req.auth_check_args,
245 auth_env,
246 req.requires_tty,
247 );
248 Ok(r)
249 }
250 },
251 );
252
253 let mgr = state.auth_session_manager.clone();
254 engine.register_typed(
255 COMMAND_AUTH_POLL,
256 move |req: PollProviderAuthReq, _ctx| {
257 let mgr = mgr.clone();
258 async move {
259 let snap = mgr
260 .poll_session(&req.session_id)
261 .ok_or_else(|| format!("auth.poll: unknown session {}", req.session_id))?;
262 Ok(snap)
263 }
264 },
265 );
266
267 let mgr = state.auth_session_manager.clone();
268 engine.register_typed(
269 COMMAND_AUTH_CANCEL,
270 move |req: CancelProviderAuthReq, _ctx| {
271 let mgr = mgr.clone();
272 async move {
273 let ok = mgr.cancel_session(&req.session_id);
274 Ok(AckResp {
275 success: ok,
276 error: if ok {
277 None
278 } else {
279 Some(format!("unknown or already-terminal session: {}", req.session_id))
280 },
281 })
282 }
283 },
284 );
285
286 let mgr = state.auth_session_manager.clone();
287 engine.register_typed(
288 COMMAND_AUTH_SUBMIT_CALLBACK,
289 move |req: SubmitAuthCallbackReq, _ctx| {
290 let mgr = mgr.clone();
291 async move {
292 let delivered = mgr
293 .send_to_stdin(&req.session_id, req.callback_url)
294 .await;
295 Ok(AckResp {
296 success: delivered,
297 error: if delivered {
298 None
299 } else {
300 Some(format!(
301 "no stdin sender for session {} (process exited or session unknown)",
302 req.session_id
303 ))
304 },
305 })
306 }
307 },
308 );
309
310 engine.register_typed(
311 COMMAND_ENSURE_ACCOUNT_DIR,
312 move |req: EnsureAccountDirReq, _ctx| {
313 let mstore = mstore_for_ensure_dir.clone();
314 async move {
315 let mut auth_env = std::collections::HashMap::new();
316 let (account_id, dir) = compute_and_ensure_account_dir(
317 &mstore,
318 &req.existing_account_id,
319 &req.provider_id,
320 &mut auth_env,
321 );
322 let resp = EnsureAccountDirResp { account_id, dir };
323 Ok(resp)
324 }
325 },
326 );
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
340 fn start_req_parses_minimal() {
341 let v = serde_json::json!({
342 "providerId": "claude",
343 "cliPath": "/usr/bin/claude",
344 "authLoginArgs": ["login"],
345 "authCheckArgs": ["whoami"]
346 });
347 let r: StartProviderAuthReq = serde_json::from_value(v).unwrap();
348 assert_eq!(r.provider_id, "claude");
349 assert_eq!(r.cli_path, "/usr/bin/claude");
350 assert_eq!(r.auth_login_args, vec!["login"]);
351 assert_eq!(r.auth_check_args, vec!["whoami"]);
352 assert!(r.into_bundle_id.is_none());
353 assert!(r.auth_env.is_empty());
354 }
355
356 #[test]
357 fn start_req_parses_with_bundle_id() {
358 let v = serde_json::json!({
359 "providerId": "codex",
360 "cliPath": "/usr/bin/codex",
361 "authLoginArgs": ["auth", "login"],
362 "authCheckArgs": ["auth", "status"],
363 "authEnv": { "FOO": "bar" },
364 "intoBundleId": "bundle-1"
365 });
366 let r: StartProviderAuthReq = serde_json::from_value(v).unwrap();
367 assert_eq!(r.provider_id, "codex");
368 assert_eq!(r.into_bundle_id.as_deref(), Some("bundle-1"));
369 assert_eq!(r.auth_env.get("FOO").map(String::as_str), Some("bar"));
370 }
371
372 #[test]
373 fn poll_req_round_trips() {
374 let v = serde_json::json!({ "sessionId": "auth-xyz" });
375 let r: PollProviderAuthReq = serde_json::from_value(v).unwrap();
376 assert_eq!(r.session_id, "auth-xyz");
377 }
378
379 #[test]
380 fn submit_callback_req_round_trips() {
381 let v = serde_json::json!({
382 "sessionId": "auth-xyz",
383 "callbackUrl": "https://example.com/cb?code=abc"
384 });
385 let r: SubmitAuthCallbackReq = serde_json::from_value(v).unwrap();
386 assert_eq!(r.session_id, "auth-xyz");
387 assert!(r.callback_url.contains("code=abc"));
388 }
389
390 #[test]
391 fn ack_resp_omits_error_when_success() {
392 let r = AckResp { success: true, error: None };
393 let v = serde_json::to_value(&r).unwrap();
394 assert_eq!(v, serde_json::json!({ "success": true }));
395 }
396
397 #[test]
398 fn ack_resp_includes_error_when_failure() {
399 let r = AckResp { success: false, error: Some("boom".into()) };
400 let v = serde_json::to_value(&r).unwrap();
401 assert_eq!(v, serde_json::json!({ "success": false, "error": "boom" }));
402 }
403
404 #[test]
407 fn start_req_parses_direct_account_fields() {
408 let v = serde_json::json!({
409 "providerId": "claude",
410 "cliPath": "/usr/bin/claude",
411 "authLoginArgs": ["login"],
412 "authCheckArgs": ["whoami"],
413 "directAccount": true,
414 "existingAccountId": "acc-1"
415 });
416 let r: StartProviderAuthReq = serde_json::from_value(v).unwrap();
417 assert!(r.direct_account);
418 assert_eq!(r.existing_account_id, "acc-1");
419 }
420
421 #[test]
422 fn start_req_direct_account_fields_default_when_absent() {
423 let v = serde_json::json!({
427 "providerId": "claude",
428 "cliPath": "/usr/bin/claude",
429 "authLoginArgs": ["login"],
430 "authCheckArgs": ["whoami"]
431 });
432 let r: StartProviderAuthReq = serde_json::from_value(v).unwrap();
433 assert!(!r.direct_account);
434 assert_eq!(r.existing_account_id, "");
435 }
436}
437
438#[cfg(test)]
440mod req_shape_tests {
441 use super::*;
442 use serde_json::json;
443
444 #[test]
450 fn auth_start_requires_only_four_fields() {
451 let minimal: StartProviderAuthReq = serde_json::from_value(json!({
452 "providerId": "claude",
453 "cliPath": "/usr/bin/claude",
454 "authLoginArgs": ["login"],
455 "authCheckArgs": ["whoami"],
456 }))
457 .expect("auth.start must accept just the four required fields");
458 assert!(minimal.into_bundle_id.is_none());
459 assert!(!minimal.direct_account);
460 assert_eq!(minimal.existing_account_id, "");
461 assert!(minimal.auth_env.is_empty());
462 assert!(!minimal.requires_tty);
463
464 assert!(
465 serde_json::from_value::<StartProviderAuthReq>(json!({"providerId": "c"})).is_err(),
466 "cliPath and the two arg lists have no defaults"
467 );
468 }
469
470 #[test]
471 fn the_session_id_commands_accept_their_payloads() {
472 serde_json::from_value::<PollProviderAuthReq>(json!({"sessionId": "s1"}))
473 .expect("auth.poll");
474 serde_json::from_value::<CancelProviderAuthReq>(json!({"sessionId": "s1"}))
475 .expect("auth.cancel");
476 serde_json::from_value::<SubmitAuthCallbackReq>(
477 json!({"sessionId": "s1", "callbackUrl": "http://localhost/cb"}),
478 )
479 .expect("auth.submitcallback");
480 serde_json::from_value::<EnsureAccountDirReq>(json!({"providerId": "claude"}))
481 .expect("identity.ensureaccountdir, existingAccountId omitted");
482
483 assert!(
484 serde_json::from_value::<PollProviderAuthReq>(json!({"session_id": "s1"})).is_err(),
485 "snake_case is not the wire format for these"
486 );
487 }
488
489 #[test]
497 fn the_ack_shapes_omit_their_optional_keys_rather_than_nulling_them() {
498 let ok = serde_json::to_value(AckResp { success: true, error: None }).expect("serializable");
499 assert_eq!(ok, json!({"success": true}));
500
501 let dir = serde_json::to_value(EnsureAccountDirResp {
502 account_id: "a1".to_string(),
503 dir: None,
504 })
505 .expect("serializable");
506 assert_eq!(dir, json!({"accountId": "a1"}));
507 }
508}