1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//! Where a subscription credential lives, so every refresh path can re-read it,
//! lock it, and write a rotated refresh token back.
//!
//! Refresh-token rotation turns a credential file into shared mutable state.
//! The vendor CLI, another router process, and this process each hold a link in
//! the same chain, and only the newest link is redeemable — redeeming an older
//! one answers `invalid_grant`. A refresh whose result stays in memory
//! therefore loses more than an optimisation: it leaves a spent link on disk for
//! the next process start to replay, and it cannot tell a *revoked* credential
//! from one another holder has merely rotated past (issue #239).
//!
//! Abstracting the file behind a trait keeps [`crate::refresh`] free of any
//! particular vendor layout and lets tests drive the same recovery ladder
//! against an in-memory store.
//!
//! Secrets are never logged here; only the path a credential was read from is.
use std::path::{Path, PathBuf};
use crate::subscription::{SubscriptionReader, SubscriptionToken};
/// Suffix of the sidecar lock file guarding a credential's read → refresh →
/// write cycle.
///
/// A sidecar rather than the credential file itself: locking the credential
/// would attach the lock to an inode that [`crate::durable_file`] replaces by
/// rename on every write, so two holders could each end up locking a different
/// file and believing they were alone.
const LOCK_SUFFIX: &str = ".router-refresh.lock";
/// A durable home for one subscription credential.
///
/// Implementors must be cheap to `reload`: the refresh path re-reads the store
/// whenever it is about to conclude something about a credential.
pub trait CredentialStore: std::fmt::Debug + Send + Sync {
/// Re-read the credential as it exists *now*, or `None` when it cannot be
/// read. A missing or malformed file is not an error here — the caller
/// simply continues with the token it already has.
fn reload(&self) -> Option<SubscriptionToken>;
/// Write a refreshed credential back, preserving vendor fields this crate
/// does not model.
///
/// # Errors
///
/// Returns an operator-readable message when the write cannot land, such as
/// on a read-only credential mount.
fn persist(&self, token: &SubscriptionToken) -> Result<(), String>;
/// Path of the advisory lock guarding this credential, when one applies.
fn lock_path(&self) -> Option<PathBuf>;
/// Where this credential lives, for logs and operator messages.
fn describe(&self) -> String;
}
impl CredentialStore for SubscriptionReader {
fn reload(&self) -> Option<SubscriptionToken> {
match self.read_token() {
Ok(token) => Some(token),
Err(error) => {
tracing::debug!(
"could not re-read the {} credential from {}: {error}",
self.provider(),
self.home().display()
);
None
}
}
}
fn persist(&self, token: &SubscriptionToken) -> Result<(), String> {
self.write_token(token).map_err(|error| error.to_string())
}
fn lock_path(&self) -> Option<PathBuf> {
// Lock beside the file that would actually be rewritten, falling back to
// the most specific candidate so two holders agree on a path even before
// either has created the credential.
let credential = self
.discover_credential_path()
.or_else(|| self.credential_paths().into_iter().next())?;
Some(lock_path_for(&credential))
}
fn describe(&self) -> String {
self.discover_credential_path()
.unwrap_or_else(|| self.home().to_path_buf())
.display()
.to_string()
}
}
/// Sidecar lock path for a credential file.
#[must_use]
pub fn lock_path_for(credential: &Path) -> PathBuf {
let mut name = credential.file_name().map_or_else(
|| String::from("credential"),
|n| n.to_string_lossy().into(),
);
name.push_str(LOCK_SUFFIX);
credential.with_file_name(name)
}
/// Whether two credentials are the same chain link.
///
/// Compares only the fields a refresh can change. Routing metadata
/// (`account_id`, `resource_url`) is deliberately excluded: a vendor CLI
/// rewriting the file may reorder or re-derive it without the token itself
/// having moved.
#[must_use]
pub fn is_same_link(a: &SubscriptionToken, b: &SubscriptionToken) -> bool {
a.access_token == b.access_token
&& a.refresh_token == b.refresh_token
&& a.expires_at_ms == b.expires_at_ms
}
/// Whether `candidate` carries a refresh token different from `current`'s.
///
/// A newer *refresh* link is the only thing worth spending another exchange on:
/// the same link would be rejected exactly as it just was.
#[must_use]
pub fn has_newer_refresh_link(current: &SubscriptionToken, candidate: &SubscriptionToken) -> bool {
candidate
.refresh_token
.as_deref()
.is_some_and(|link| !link.is_empty() && Some(link) != current.refresh_token.as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::subscription::SubscriptionProvider;
fn token(access: &str, refresh: Option<&str>) -> SubscriptionToken {
SubscriptionToken {
access_token: access.into(),
refresh_token: refresh.map(Into::into),
expires_at_ms: Some(1),
account_id: None,
resource_url: None,
}
}
#[test]
fn a_reader_round_trips_through_the_store_trait() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("auth.json"),
r#"{"tokens":{"access_token":"a","refresh_token":"r"}}"#,
)
.expect("seed");
let reader = SubscriptionReader::new(SubscriptionProvider::Codex, dir.path());
let store: &dyn CredentialStore = &reader;
let loaded = store.reload().expect("reload");
assert_eq!(loaded.refresh_token.as_deref(), Some("r"));
store.persist(&token("a2", Some("r2"))).expect("persist");
assert_eq!(
store.reload().expect("reload").refresh_token.as_deref(),
Some("r2")
);
// The lock is a sidecar, never the credential file itself.
let lock = store.lock_path().expect("lock path");
assert_ne!(lock, dir.path().join("auth.json"));
assert!(lock.to_string_lossy().ends_with(LOCK_SUFFIX), "{lock:?}");
assert!(store.describe().contains("auth.json"));
}
/// A store with nothing readable must report `None` rather than panic, so a
/// missing credential is just "nothing newer to adopt".
#[test]
fn an_unreadable_store_reloads_to_nothing() {
let dir = tempfile::tempdir().expect("temp dir");
let reader = SubscriptionReader::new(SubscriptionProvider::Codex, dir.path());
assert!(CredentialStore::reload(&reader).is_none());
assert!(CredentialStore::lock_path(&reader).is_some());
}
#[test]
fn link_comparison_ignores_routing_metadata() {
let mut other = token("a", Some("r"));
other.account_id = Some("acct".into());
assert!(is_same_link(&token("a", Some("r")), &other));
assert!(!is_same_link(
&token("a", Some("r")),
&token("a2", Some("r"))
));
assert!(has_newer_refresh_link(
&token("a", Some("r")),
&token("a", Some("r2"))
));
assert!(!has_newer_refresh_link(
&token("a", Some("r")),
&token("a", Some("r"))
));
// A store that lost its refresh token has nothing newer to offer.
assert!(!has_newer_refresh_link(
&token("a", Some("r")),
&token("a", None)
));
}
}