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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
//! Auth errors (AUTH-6). The class NAME and the ErrorCode are the family
//! (playbooks/api-family.md § Errors); the mechanism is this enum.
//!
//! Messages never carry token material (AUTH-5): every field is a provider
//! id, a policy name, a scheme name or a fix hint.
use std::fmt;
use super::policy::known_providers;
#[derive(Debug, Clone, PartialEq)]
pub enum AuthError {
/// The provider string names nothing in the access-policy table.
/// Class `NotConfiguredError`, code `not_configured`.
UnknownProvider { provider: String },
/// No usable credential source (missing/unreadable/malformed file, no
/// key, or a credential kind the door's schemes cannot carry). The
/// hint names the fix. Class `NotConfiguredError`, code `not_configured`.
NotConfigured {
provider: Option<String>,
message: String,
hint: Option<String>,
},
/// A stored login that is expired and cannot be sent (AUTH-6:
/// expired-and-unrefreshable → the `AuthError` class, same hint
/// discipline). `refreshable`: the file holds a refresh token.
/// `refresh_configured`: this `StoredLogin` can refresh
/// (`StoredLogin::refreshing`) — the refresh runs in the adapter's
/// async `prepare` step, which a hand-built request skips.
/// Class `AuthError`, code `auth`.
Expired {
provider: String,
refreshable: bool,
refresh_configured: bool,
hint: String,
},
/// The credential-file lock could not be taken within the timeout
/// (AUTH-4): another lm15 process is refreshing the same credential.
/// Local and transient — deliberately NOT an auth failure (AUTH-6:
/// nothing is wrong with the credential). Class `LockTimeoutError`,
/// code `lock_timeout` (retryable; 2026-09-08).
LockTimeout {
path: String,
lock_path: String,
timeout_secs: u64,
},
/// AUTH-9: the device code expired before the user approved it — a
/// typed error distinct from denial. Class `AuthError`, code `auth`.
DeviceCodeExpired { provider: String },
/// A credential source that answered and was refused, or answered
/// something unusable (an STS 403, a token exchange 400, a malformed
/// credential_process output): AUTH-6 "provider-rejected". Class
/// `AuthError`, code `auth`.
Rejected {
provider: Option<String>,
message: String,
hint: Option<String>,
},
/// An lm15 error carried through the auth plumbing unchanged (a managed
/// connection's `AuthOperationError`, a renewal's `ServerError` …): the
/// credential `prepare` step returns `AuthError`, and the class must
/// reach the caller as it was raised.
Lm15(Box<crate::errors::Lm15Error>),
}
impl AuthError {
/// The ErrorCode literal (spec/vocabularies.md ErrorCode).
pub fn code(&self) -> &'static str {
match self {
AuthError::Expired { .. }
| AuthError::Rejected { .. }
| AuthError::DeviceCodeExpired { .. } => "auth",
AuthError::LockTimeout { .. } => "lock_timeout",
AuthError::Lm15(err) => err.code().as_str(),
_ => "not_configured",
}
}
/// The canonical class name (spec/vocabularies.md ErrorCode table).
pub fn class_name(&self) -> &'static str {
match self {
AuthError::Expired { .. }
| AuthError::Rejected { .. }
| AuthError::DeviceCodeExpired { .. } => "AuthError",
AuthError::LockTimeout { .. } => "LockTimeoutError",
AuthError::Lm15(err) => err.class_name(),
_ => "NotConfiguredError",
}
}
/// The provider this error is about, when known.
pub fn provider(&self) -> Option<&str> {
match self {
AuthError::UnknownProvider { provider }
| AuthError::Expired { provider, .. }
| AuthError::DeviceCodeExpired { provider } => Some(provider),
AuthError::NotConfigured { provider, .. } | AuthError::Rejected { provider, .. } => {
provider.as_deref()
}
AuthError::LockTimeout { .. } => None,
AuthError::Lm15(err) => err.provider(),
}
}
pub(crate) fn not_configured(
provider: impl Into<String>,
message: impl Into<String>,
hint: impl Into<String>,
) -> Self {
AuthError::NotConfigured {
provider: Some(provider.into()),
message: message.into(),
hint: Some(hint.into()),
}
}
}
impl fmt::Display for AuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuthError::UnknownProvider { provider } => write!(
f,
"unknown provider {provider:?}; known providers: {}",
known_providers().join(", ")
),
AuthError::NotConfigured {
provider,
message,
hint,
} => {
if let Some(provider) = provider {
write!(f, "{provider}: ")?;
}
f.write_str(message)?;
if let Some(hint) = hint {
write!(f, "\n\n To fix:\n - {hint}\n")?;
}
Ok(())
}
AuthError::Rejected {
provider,
message,
hint,
} => {
if let Some(provider) = provider {
write!(f, "{provider}: ")?;
}
f.write_str(message)?;
if let Some(hint) = hint {
write!(f, "\n\n To fix:\n - {hint}\n")?;
}
Ok(())
}
AuthError::Expired {
provider,
refreshable,
refresh_configured,
hint,
} => {
if *refreshable && *refresh_configured {
write!(
f,
"{provider}: the stored login is expired; it holds a refresh token, and \
the refresh runs in the adapter's async `prepare` step before \
`complete` / `stream` — a request built by hand skips it. Send \
through the adapter, or refresh with the provider's own tool\n\n \
To fix:\n - {hint}\n"
)
} else if *refreshable {
write!(
f,
"{provider}: the stored login is expired; it holds a refresh token, \
but this StoredLogin was built without a refresh transport \
(StoredLogin::refreshing; the router enables it when HOME or \
LM15_LOCK_DIR gives it a lock directory) — build through the \
router, or refresh with the provider's own tool\n\n \
To fix:\n - {hint}\n"
)
} else {
write!(
f,
"{provider}: the stored login is expired and has no refresh token\n\n \
To fix:\n - {hint}\n"
)
}
}
AuthError::LockTimeout {
path,
lock_path,
timeout_secs,
} => write!(
f,
"Could not lock credential file {path} within {timeout_secs}s (lock file: \
{lock_path}). Another process may be refreshing the same credential; retry, \
or remove a stale lock only if you are certain no other process holds it."
),
AuthError::DeviceCodeExpired { provider } => write!(
f,
"{provider}: device authorization expired before it was approved. Start the \
login again."
),
AuthError::Lm15(err) => f.write_str(err.message()),
}
}
}
impl std::error::Error for AuthError {}
impl From<AuthError> for crate::errors::Lm15Error {
/// AUTH-6: a missing / unreadable / unimplemented source is a
/// `NotConfiguredError`; an expired login is an `AuthError`. The
/// message is the redacted rendering, the provider is carried when
/// known.
fn from(err: AuthError) -> Self {
if let AuthError::Lm15(inner) = err {
return *inner;
}
let mut meta = crate::errors::ErrorMeta::new(err.to_string());
meta.provider = err.provider().map(str::to_string);
match err {
AuthError::Expired { .. }
| AuthError::Rejected { .. }
| AuthError::DeviceCodeExpired { .. } => crate::errors::Lm15Error::AuthError(meta),
AuthError::LockTimeout {
path, lock_path, ..
} => crate::errors::Lm15Error::LockTimeoutError(crate::errors::LockTimeout {
meta,
path,
lock_path,
}),
_ => crate::errors::Lm15Error::NotConfiguredError(meta),
}
}
}