1mod enroll;
10mod forget;
11mod journal;
12mod rename;
13mod renew;
14mod uninstall;
15
16pub use enroll::{Enrolled, SignIn, WatchedSignIn, enroll, sign_in, sign_in_watched};
17pub use forget::forget;
18pub use journal::{Abandoned, Recovered, pending as interrupted};
19pub use rename::rename;
20pub use renew::{Renewal, renew_parked};
21pub use uninstall::{Removed, uninstall};
22
23use crate::context::Context;
24use crate::error::{Error, Result};
25use crate::service::Warning;
26use crate::state::{Account, Park, State};
27use crate::{api, claude, configfile, home, lock, park, state, store, time};
28use journal::{Journal, clear_journal, reconcile, write_journal};
29use serde_json::Value;
30use std::os::unix::fs::OpenOptionsExt;
31use std::path::PathBuf;
32
33pub const ADOPTION_CEILING_SECONDS: u32 = 33;
38
39pub enum Outcome {
40 Switched {
41 from: String,
42 to: String,
43 parked: Park,
44 },
45 AlreadyActive { label: String },
47}
48
49fn oauth_of(document: &Value) -> Result<Value> {
50 document
51 .get("claudeAiOauth")
52 .cloned()
53 .ok_or_else(|| Error::LiveCredentialShapeUnexpected {
54 detail: "it has no claudeAiOauth block".into(),
55 })
56}
57
58pub struct Settled {
61 _exclusive: std::fs::File,
62 state: State,
63 ctx: Context,
64}
65
66pub fn abandon(ctx: &Context) -> Result<Option<Abandoned>> {
69 if ctx.custom_oauth {
70 return Err(Error::CustomOauthEndpoint);
71 }
72 let _exclusive = exclusive(ctx)?;
73 let mut state = state::load(ctx)?;
74 journal::abandon(ctx, &mut state)
75}
76
77pub fn settle(ctx: &Context) -> Result<(Settled, Option<Recovered>)> {
80 if ctx.custom_oauth {
84 return Err(Error::CustomOauthEndpoint);
85 }
86 let exclusive = exclusive(ctx)?;
87 let mut state = state::load(ctx)?;
88 let recovered = reconcile(ctx, &mut state)?;
89 purge(ctx, &mut state);
90 Ok((
91 Settled {
92 _exclusive: exclusive,
93 state,
94 ctx: ctx.clone(),
95 },
96 recovered,
97 ))
98}
99
100fn purge(ctx: &Context, state: &mut State) -> usize {
103 let listed = state.discarded.len();
104 let remaining = park::purge(ctx, state);
105 if remaining != listed {
106 let _ = state::save(ctx, state);
107 }
108 remaining
109}
110
111fn exclusive(ctx: &Context) -> Result<std::fs::File> {
115 let (file, path) = lock_file(ctx)?;
116 file.lock()
117 .map_err(|source| Error::HomeUnwritable { path, source })?;
118 Ok(file)
119}
120
121fn try_exclusive(ctx: &Context) -> Option<std::fs::File> {
123 let (file, _) = lock_file(ctx).ok()?;
124 file.try_lock().ok()?;
125 Some(file)
126}
127
128fn lock_file(ctx: &Context) -> Result<(std::fs::File, PathBuf)> {
129 let path = home::dir(ctx).join("state.lock");
130 let fail = |source| Error::HomeUnwritable {
131 path: path.clone(),
132 source,
133 };
134 home::ensure(ctx).map_err(fail)?;
135 let file = std::fs::OpenOptions::new()
136 .create(true)
137 .truncate(false)
138 .write(true)
139 .mode(0o600)
140 .open(&path)
141 .map_err(fail)?;
142 Ok((file, path))
143}
144
145fn identify(ctx: &Context, access_token: &str) -> Result<api::Owner> {
148 api::owner(ctx, access_token).map_err(|e| match e {
149 api::ApiError::Unauthorized => Error::SessionExpired,
150 other => Error::IdentityUnverifiable {
151 detail: other.to_string(),
152 },
153 })
154}
155
156fn access_token(document: &Value) -> Result<String> {
157 document["claudeAiOauth"]["accessToken"]
158 .as_str()
159 .map(str::to_owned)
160 .ok_or_else(|| Error::LiveCredentialShapeUnexpected {
161 detail: "it has no access token".into(),
162 })
163}
164
165pub fn switch(settled: Settled, label: &str) -> Result<(Outcome, Vec<Warning>)> {
166 let Settled {
167 _exclusive,
168 mut state,
169 ctx,
170 } = settled;
171 let ctx = &ctx;
172 let target = state
173 .get(label)
174 .cloned()
175 .ok_or_else(|| Error::AccountUnknown {
176 label: label.to_string(),
177 enrolled: state.labels(),
178 })?;
179
180 let service = claude::live_service(ctx);
183 let live = store::read(ctx, &service)?.ok_or(Error::LiveCredentialAbsent)?;
184 let identified_with = access_token(&live)?;
185 let outgoing = identify(ctx, &identified_with)?;
186
187 if outgoing.account_uuid == target.account_uuid {
188 if state.active.as_deref() != Some(label) {
189 state.active = Some(label.to_string());
190 state::save(ctx, &state)?;
191 }
192 return Ok((
193 Outcome::AlreadyActive {
194 label: label.to_string(),
195 },
196 Vec::new(),
197 ));
198 }
199 let outgoing_label = state
200 .by_uuid(&outgoing.account_uuid)
201 .map(|a| a.label.clone())
202 .ok_or_else(|| Error::LiveAccountNotEnrolled {
203 email: outgoing.email.clone(),
204 })?;
205 let held = target.parked.clone().ok_or_else(|| Error::NothingParked {
206 label: label.to_string(),
207 })?;
208 if !held.restorable_at(time::now()) {
209 return Err(Error::ParkedLoginExpired {
210 label: label.to_string(),
211 });
212 }
213 let incoming = park::load(ctx, label, &held)?;
214
215 let storage = PathBuf::from(claude::storage_dir(ctx)).join(".storage-write");
216 let guard = lock::acquire(&storage)?;
217
218 let before_raw = store::read_raw(ctx, &service)?.ok_or(Error::LiveCredentialAbsent)?;
219 let before: Value =
220 serde_json::from_str(&before_raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
221 detail: e.to_string(),
222 })?;
223 let now_token = access_token(&before)?;
226 if now_token != identified_with
227 && identify(ctx, &now_token)?.account_uuid != outgoing.account_uuid
228 {
229 return Err(Error::SignedInAccountChanged);
230 }
231
232 let next = splice(&before, &incoming)?;
235 if store::too_large(ctx, &service, &next) {
236 let (bytes, limit) = store::cost(ctx, &service, &next).unwrap_or_default();
237 return Err(Error::CredentialTooLarge {
238 label: label.to_string(),
239 bytes,
240 limit,
241 });
242 }
243
244 let park_service = park::reserve(ctx, &outgoing.account_uuid)?;
245 write_journal(
246 ctx,
247 &Journal {
248 started_at: time::now(),
249 from_label: outgoing_label.clone(),
250 from_uuid: outgoing.account_uuid.clone(),
251 to_label: label.to_string(),
252 to_uuid: target.account_uuid.clone(),
253 park_service: park_service.clone(),
254 incoming_service: held.service.clone(),
255 },
256 )?;
257
258 let parked = match park::store_at(ctx, &park_service, &oauth_of(&before)?) {
262 Ok(parked) => parked,
263 Err(e) => {
264 clear_journal(ctx);
265 return Err(e);
266 }
267 };
268 state.park(&outgoing_label, parked.clone());
269 if let Err(e) = state::save(ctx, &state) {
270 let _ = store::vault_delete(ctx, &parked.service);
271 clear_journal(ctx);
272 return Err(e);
273 }
274
275 if let Err(e) = install(ctx, &service, &next, &before_raw, &outgoing_label, label) {
276 if !only_copy_left(&e) {
277 state.discard(&parked.service);
278 }
279 state::save(ctx, &state)?;
280 clear_journal(ctx);
281 purge(ctx, &mut state);
282 return Err(e);
283 }
284 state.discard(&held.service);
285 state.active = Some(label.to_string());
286 state::save(ctx, &state)?;
287 drop(guard);
288
289 let config_warning = update_config(
291 ctx,
292 &target,
293 &outgoing.account_uuid,
294 &outgoing.organization_uuid,
295 )
296 .err()
297 .map(Warning::ConfigNotUpdated);
298 let parks_pending = purge(ctx, &mut state);
299 clear_journal(ctx);
300
301 let warnings = config_warning
302 .into_iter()
303 .chain((parks_pending > 0).then_some(Warning::ParksPendingRemoval(parks_pending)))
304 .collect();
305 Ok((
306 Outcome::Switched {
307 from: outgoing_label,
308 to: label.to_string(),
309 parked,
310 },
311 warnings,
312 ))
313}
314
315fn only_copy_left(failure: &Error) -> bool {
319 matches!(failure, Error::SwitchCorrupted { .. })
320}
321
322const ACCOUNT_SCOPED: [&str; 4] = [
328 "organizationUuid",
329 "trustedDeviceToken",
330 "enterpriseGateway",
331 "designOauth",
332];
333
334fn splice(before: &Value, incoming: &Value) -> Result<String> {
338 let mut next = before.clone();
339 let document = next
340 .as_object_mut()
341 .ok_or_else(|| Error::LiveCredentialShapeUnexpected {
342 detail: "it is not a JSON object".into(),
343 })?;
344 document.insert("claudeAiOauth".into(), incoming.clone());
345 for key in ACCOUNT_SCOPED {
346 document.remove(key);
347 }
348 Ok(serde_json::to_string(&next).expect("a credential document stays serialisable"))
349}
350
351fn install(
352 ctx: &Context,
353 service: &str,
354 next: &str,
355 before_raw: &str,
356 from: &str,
357 to: &str,
358) -> Result<()> {
359 install_with(
360 |body| store::write_raw(ctx, service, body),
361 || store::read_raw(ctx, service),
362 next,
363 before_raw,
364 from,
365 to,
366 )
367}
368
369fn install_with(
373 write: impl Fn(&str) -> std::result::Result<(), store::Error>,
374 read: impl Fn() -> std::result::Result<Option<String>, store::Error>,
375 next: &str,
376 before_raw: &str,
377 from: &str,
378 to: &str,
379) -> Result<()> {
380 let Err(failure) = write(next) else {
381 return Ok(());
382 };
383 let rolled_back = |detail: String| Error::SwitchRolledBack {
384 from: from.to_string(),
385 to: to.to_string(),
386 detail,
387 };
388 if matches!(read(), Ok(Some(now)) if now == before_raw) {
389 return Err(rolled_back(failure.to_string()));
390 }
391 match write(before_raw) {
392 Ok(()) => Err(rolled_back(failure.to_string())),
393 Err(rollback) => Err(Error::SwitchCorrupted {
394 from: from.to_string(),
395 to: to.to_string(),
396 detail: format!("{failure}; {rollback}"),
397 }),
398 }
399}
400
401fn update_config(
404 ctx: &Context,
405 target: &Account,
406 outgoing_account: &str,
407 outgoing_org: &str,
408) -> Result<()> {
409 let path = claude::config_file(ctx);
410 configfile::backup(ctx, &path)?;
411 let mut config = claude::load_config(ctx)?;
412 configfile::splice_identity(
413 &mut config,
414 &target.oauth_account,
415 &[outgoing_account, outgoing_org],
416 );
417 configfile::write(&path, &config)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
428 fn a_switch_leaves_nothing_of_the_outgoing_account() {
429 let before = serde_json::json!({
430 "claudeAiOauth": {"refreshToken": "old"},
431 "organizationUuid": "org-a",
432 "trustedDeviceToken": "device-of-a",
433 "enterpriseGateway": {"url": "https://gateway.example"},
434 "designOauth": {"refreshToken": "design-of-a"},
435 "somethingOfThisMachine": true,
436 });
437 let after: Value = serde_json::from_str(
438 &splice(&before, &serde_json::json!({"refreshToken": "new"})).expect("spliced"),
439 )
440 .expect("valid JSON");
441 assert_eq!(after["claudeAiOauth"]["refreshToken"], "new");
442 assert_eq!(after["somethingOfThisMachine"], true);
443 for key in ACCOUNT_SCOPED {
444 assert!(after.get(key).is_none(), "{key} was left behind");
445 }
446 }
447
448 use std::cell::RefCell;
449
450 fn failing(message: &str) -> store::Error {
451 store::Error::Write(message.into())
452 }
453
454 #[test]
455 fn a_successful_write_needs_no_rollback() {
456 let written = RefCell::new(Vec::new());
457 let result = install_with(
458 |b| {
459 written.borrow_mut().push(b.to_string());
460 Ok(())
461 },
462 || unreachable!(),
463 "new",
464 "old",
465 "a",
466 "b",
467 );
468 assert!(result.is_ok());
469 assert_eq!(*written.borrow(), vec!["new"]);
470 }
471
472 #[test]
473 fn a_failed_write_that_changed_nothing_is_not_reported_as_a_lost_login() {
474 let result = install_with(
475 |_| Err(failing("keychain locked")),
476 || Ok(Some("old".into())),
477 "new",
478 "old",
479 "a",
480 "b",
481 );
482 assert!(
483 matches!(result, Err(Error::SwitchRolledBack { .. })),
484 "the old login never left, so the user must not be told to sign in again"
485 );
486 }
487
488 #[test]
489 fn a_half_write_is_rolled_back() {
490 let slot = RefCell::new("old".to_string());
491 let result = install_with(
492 |b| {
493 if b == "new" {
494 *slot.borrow_mut() = "garbled".into();
495 Err(failing("interrupted"))
496 } else {
497 *slot.borrow_mut() = b.to_string();
498 Ok(())
499 }
500 },
501 || Ok(Some(slot.borrow().clone())),
502 "new",
503 "old",
504 "a",
505 "b",
506 );
507 assert!(matches!(result, Err(Error::SwitchRolledBack { .. })));
508 assert_eq!(
509 *slot.borrow(),
510 "old",
511 "the previous login must be back in place"
512 );
513 }
514
515 #[test]
516 fn only_a_failed_rollback_after_a_change_is_reported_as_corruption() {
517 let result = install_with(
518 |_| Err(failing("disk full")),
519 || Ok(Some("garbled".into())),
520 "new",
521 "old",
522 "a",
523 "b",
524 );
525 assert!(matches!(result, Err(Error::SwitchCorrupted { .. })));
526 }
527
528 #[test]
529 fn a_copy_is_kept_after_a_failed_install_only_when_it_is_all_that_is_left() {
530 let (from, to, detail) = ("a".to_string(), "b".to_string(), String::new());
531 assert!(!only_copy_left(&Error::SwitchRolledBack {
532 from: from.clone(),
533 to: to.clone(),
534 detail: detail.clone(),
535 }));
536 assert!(only_copy_left(&Error::SwitchCorrupted { from, to, detail }));
537 }
538
539 #[test]
540 fn a_credential_without_claude_ai_oauth_is_refused() {
541 assert!(oauth_of(&serde_json::json!({"slackTag": {}})).is_err());
542 assert!(oauth_of(&serde_json::json!({"claudeAiOauth": {"accessToken": "a"}})).is_ok());
543 }
544}