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